5 Commits

Author SHA1 Message Date
Devin AI
af58c71f40 fix: remove nonsensical $X% fee format, use percentage only
Fee displays combined dollar sign with percent sign (e.g. $0.02%)
which is invalid in any financial context. Changed to percentage
format (0.02%) across all three locations: simulation results,
canvas inspector, and right panel context display.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 18:03:05 +00:00
Devin AI
1a459c695b fix: per-tab undo/redo history, stale closure in node ops, zoom animation
1. Per-tab history: Changed reducer from single HistoryState to
   Record<string, HistoryState> keyed by tab ID. Undo/redo now
   only affects the active tab. Creating a new tab initializes
   its own history; closing a tab cleans up its history.

2. Stale closure fix: onDropComponent, onConnect, deleteSelectedNodes,
   and duplicateSelectedNodes now use setTransactionTabs directly to
   read both nodes and edges from the same state snapshot, preventing
   inconsistent history entries.

3. Zoom animation: handleZoomIn/handleZoomOut now use .then() on the
   zoomIn()/zoomOut() promises to read the zoom level after animation
   completes, preventing stale zoom display.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:55:19 +00:00
Devin AI
81506c63ec fix: remove skipHistoryRef that silently drops history entry after undo/redo
skipHistoryRef was set to true in undo/redo but never consumed during
those operations (setNodes/setEdges update transactionTabs directly
without calling pushHistory). The ref stayed true indefinitely, causing
the next user action's pushHistory call to silently skip recording,
breaking the undo chain.

Since pushHistory is never called during undo/redo operations, the
skip guard serves no purpose. Removed entirely.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:41:32 +00:00
Devin AI
ce481fd2c1 fix: undo/redo history index divergence when exceeding 50 entries
Fixes two bugs identified by Devin Review on PR #1:

1. History index goes out of bounds (50) when entries exceed the
   50-entry cap, causing the first undo to be a silent no-op.
   The shift() removed an entry but the index still incremented
   past the array bounds.

2. pushHistory uses stale historyIndex closure value inside
   setHistory's functional updater, causing entries to be silently
   dropped when multiple pushHistory calls are batched by React.

Fix: Combine history entries and index into a single useReducer
state so both are always updated atomically. Add 'reset' action
for new transaction tab creation.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:33:26 +00:00
Devin AI
52676016fb feat: Solace Bank Group PLC Treasury Management Portal
- Web3 authentication with MetaMask, WalletConnect, Coinbase wallet options
- Demo mode for testing without wallet
- Overview dashboard with KPI cards, asset allocation, positions, accounts, alerts
- Transaction Builder module (full IDE-style drag-and-drop canvas with 28 gap fixes)
- Accounts module with multi-account/subaccount hierarchical structures
- Treasury Management module with positions table and 14-day cash forecast
- Financial Reporting module with IPSAS, US GAAP, IFRS compliance
- Compliance & Risk module with KYC/AML/Sanctions monitoring
- Settlement & Clearing module with DVP/FOP/PVP operations
- Settings with role-based permissions and enterprise controls
- Dark theme professional UI with Solace Bank branding
- HashRouter for static hosting compatibility

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:20:13 +00:00
40 changed files with 12484 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# Testing TransactFlow IDE
## Overview
TransactFlow is a React 18 + TypeScript + Vite app using @xyflow/react for a drag-and-drop graph editor. It deploys as a static frontend site (no backend).
## Deployed URL
https://dist-dgoompqy.devinapps.com
## Local Dev
```bash
cd /home/ubuntu/repos/transaction-builder
npm install
npm run dev # starts on localhost:5174
```
## Testing Approach
### Tool Selection
- **Browser tool**: Use for most UI interactions (clicking, typing, verifying DOM state via console). Works well for buttons, inputs, tabs, dropdowns.
- **Playwright CDP**: Required for drag-and-drop testing. React Flow's drag-and-drop uses native browser events that synthetic DOM events cannot replicate. Connect via `chromium.connectOverCDP('http://127.0.0.1:<port>')`. The Chrome CDP port may be ephemeral — find it with `ss -tlnp 2>/dev/null | grep chrome`.
- **Browser console**: Use `document.querySelector`/`querySelectorAll` for DOM assertions. Returns exact counts and text content for verification.
### Key Patterns
#### React Controlled Inputs
React controlled inputs (`<input value={state} onChange={...}>`) cannot be cleared via `element.value = ''` + synthetic events. React manages the value internally. **Workaround**: Reload the page to reset state rather than fighting React's control.
#### devinid Drift After DOM Changes
The browser tool assigns `devinid` attributes based on current DOM state. After significant DOM changes (collapsing accordions, opening modals, switching tabs), cached devinids become invalid. **Workaround**: Re-query the HTML or reload the page after major DOM mutations to get fresh devinids.
#### Keyboard Shortcuts (Ctrl+K, Ctrl+B, etc.)
Browser automation tools may intercept `Ctrl+K` before it reaches the page. Synthetic `KeyboardEvent` dispatch on `window` also might not trigger React's state updates reliably. **Workaround**: Use the UI button that triggers the same action (e.g., Command Palette icon button instead of Ctrl+K).
#### Command Palette
The command palette overlay renders as `.command-palette` with input `.command-palette-input` and results `.command-palette-results`. Commands are `.command-item` elements with `.command-label` text. The palette input might not have a visible devinid in truncated HTML — search the full page HTML file for `placeholder="Type a command"`.
### Component Architecture (for test assertions)
- **TitleBar**: Mode selector (`.mode-selector` / `.mode-dropdown` / `.mode-option`), search bar, validate/simulate/execute buttons
- **ActivityBar**: 10 `.activity-btn` icon buttons
- **LeftPanel**: Search input, filter buttons (All/Favorites/Recent), `.component-category` with `.category-header` and `.category-items`, `.component-item` elements
- **Canvas**: React Flow container, `.canvas-empty-content` (shown when `nodes.length === 0`), `.canvas-inspector` with node/connection counts
- **RightPanel**: `.chat-header-agent` shows active agent name, `.agent-tab` buttons for switching, `.chat-message.user` and `.chat-message.agent` for messages, agent responses have 800ms delay
- **BottomPanel**: `.bottom-tab` buttons, `.system-800-card` (6 cards), `.settlement-table` (4 rows), `.audit-content` with `.audit-entry` elements
- **CommandPalette**: `.command-palette-overlay`, `.command-palette`, `.command-item`, `.command-label`
### Test Data (hardcoded in source)
- Default favorites: Transfer, Swap, KYC (Set in LeftPanel.tsx)
- 7 component categories, 56 total components
- 7 agent types: Builder, Compliance, Routing, ISO-20022, Settlement, Risk, Documentation
- 6 system status cards in 800 System tab
- 4 settlement queue rows (includes TX-2024-0847)
- Audit trail contains SESSION_START and ENGINE_LOAD events
- 4 session modes: Sandbox, Simulate, Live, Compliance Review
- Builder agent responds with "Transfer" (when input doesn't contain "swap") or "Swap" (when it does)
- Compliance agent responds with "No policy violations"
## Devin Secrets Needed
None — this is a purely frontend static site with no auth required.
## Common Issues
- If Chrome CDP port is not 29229, the browser was launched with `--remote-debugging-port=0` (random). Restart browser via `browser(action="restart")` and find the new port.
- React Flow nodes require native browser drag events. Use Playwright's `dragTo()` method, not synthetic `DragEvent` dispatch.
- After page reload, all React state resets (nodes cleared, chat history cleared, filters reset to defaults). Plan test sequences accordingly.

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solace Bank Group PLC — Treasury Management Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3433
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "transaction-builder",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@xyflow/react": "^12.10.2",
"ethers": "^6.16.0",
"lucide-react": "^1.8.0",
"playwright": "^1.59.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

622
src/App.tsx Normal file
View File

@@ -0,0 +1,622 @@
import { useState, useEffect, useCallback, useRef, useReducer } from 'react';
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
import TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar';
import LeftPanel from './components/LeftPanel';
import Canvas from './components/Canvas';
import RightPanel from './components/RightPanel';
import BottomPanel from './components/BottomPanel';
import CommandPalette from './components/CommandPalette';
import type { ActivityTab, SessionMode, ComponentItem, HistoryEntry, TransactionTab, TerminalEntry, AuditEntry, ValidationIssue } from './types';
const STORAGE_KEY = 'transactflow-workspace';
function loadWorkspace() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return null;
}
function saveWorkspace(state: Record<string, unknown>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
export default function App() {
const saved = useRef(loadWorkspace());
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');
const [leftOpen, setLeftOpen] = useState(saved.current?.leftOpen ?? true);
const [rightOpen, setRightOpen] = useState(saved.current?.rightOpen ?? true);
const [bottomOpen, setBottomOpen] = useState(saved.current?.bottomOpen ?? true);
const [bottomExpanded, setBottomExpanded] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [mode, setMode] = useState<SessionMode>(saved.current?.mode || 'Sandbox');
const [leftWidth, setLeftWidth] = useState(saved.current?.leftWidth ?? 280);
const [rightWidth, setRightWidth] = useState(saved.current?.rightWidth ?? 320);
const [bottomHeight, setBottomHeight] = useState(saved.current?.bottomHeight ?? 220);
// Transaction tabs
const [transactionTabs, setTransactionTabs] = useState<TransactionTab[]>([
{ id: 'tx-1', name: 'Untitled Transaction', nodes: [], edges: [] },
]);
const [activeTransactionId, setActiveTransactionId] = useState('tx-1');
const activeTransaction = transactionTabs.find(t => t.id === activeTransactionId)!;
const nodes = activeTransaction.nodes;
const edges = activeTransaction.edges;
const setNodes = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
setTransactionTabs(prev => prev.map(t => {
if (t.id !== activeTransactionId) return t;
const newNodes = typeof updater === 'function' ? updater(t.nodes) : updater;
return { ...t, nodes: newNodes };
}));
}, [activeTransactionId]);
const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
setTransactionTabs(prev => prev.map(t => {
if (t.id !== activeTransactionId) return t;
const newEdges = typeof updater === 'function' ? updater(t.edges) : updater;
return { ...t, edges: newEdges };
}));
}, [activeTransactionId]);
// Undo/redo — per-tab history to prevent cross-tab state corruption
type HistoryState = { entries: HistoryEntry[]; index: number };
type PerTabHistoryState = Record<string, HistoryState>;
type HistoryAction =
| { type: 'push'; tabId: string; nodes: Node[]; edges: Edge[] }
| { type: 'undo'; tabId: string }
| { type: 'redo'; tabId: string }
| { type: 'reset'; tabId: string }
| { type: 'remove'; tabId: string };
const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 };
const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => {
const tabState = state[action.tabId] || defaultHistory;
switch (action.type) {
case 'push': {
const trimmed = tabState.entries.slice(0, tabState.index + 1);
const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) };
const next = [...trimmed, entry];
let newIndex = trimmed.length;
if (next.length > 50) {
next.shift();
newIndex = next.length - 1;
}
return { ...state, [action.tabId]: { entries: next, index: newIndex } };
}
case 'undo': {
if (tabState.index <= 0) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index - 1 } };
}
case 'redo': {
if (tabState.index >= tabState.entries.length - 1) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index + 1 } };
}
case 'reset': {
return { ...state, [action.tabId]: { entries: [{ nodes: [], edges: [] }], index: 0 } };
}
case 'remove': {
const { [action.tabId]: _, ...rest } = state;
return rest;
}
}
}, []);
const [historyState, dispatchHistory] = useReducer(historyReducer, { 'tx-1': { entries: [{ nodes: [], edges: [] }], index: 0 } });
const activeHistory = historyState[activeTransactionId] || defaultHistory;
const history = activeHistory.entries;
const historyIndex = activeHistory.index;
const pushHistory = useCallback((tabId: string, n: Node[], e: Edge[]) => {
dispatchHistory({ type: 'push', tabId, nodes: n, edges: e });
}, []);
const undo = useCallback(() => {
const tabHistory = historyState[activeTransactionId] || defaultHistory;
const entry = tabHistory.entries[tabHistory.index - 1];
if (!entry || tabHistory.index <= 0) return;
dispatchHistory({ type: 'undo', tabId: activeTransactionId });
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]);
const redo = useCallback(() => {
const tabHistory = historyState[activeTransactionId] || defaultHistory;
const entry = tabHistory.entries[tabHistory.index + 1];
if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return;
dispatchHistory({ type: 'redo', tabId: activeTransactionId });
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]);
// Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodes = nodes.filter(n => selectedNodeIds.has(n.id));
// Recent components
const [recentComponents, setRecentComponents] = useState<string[]>([]);
const addRecentComponent = useCallback((id: string) => {
setRecentComponents(prev => {
const next = [id, ...prev.filter(x => x !== id)];
return next.slice(0, 20);
});
}, []);
// Terminal log entries (live)
const [terminalEntries, setTerminalEntries] = useState<TerminalEntry[]>([]);
const addTerminalEntry = useCallback((level: TerminalEntry['level'], source: string, message: string) => {
setTerminalEntries(prev => [...prev, {
id: Date.now().toString(),
timestamp: new Date(),
level,
source,
message,
}]);
}, []);
// Audit entries (live)
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const addAuditEntry = useCallback((action: string, detail: string) => {
setAuditEntries(prev => [...prev, {
id: Date.now().toString(),
timestamp: new Date(),
user: 'user',
action,
detail,
}]);
}, []);
// Validation state
const [validationIssues, setValidationIssues] = useState<ValidationIssue[]>([]);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationResults, setSimulationResults] = useState<string | null>(null);
// Split view
const [splitView, setSplitView] = useState(false);
// Resizable panels
const resizing = useRef<{ side: 'left' | 'right' | 'bottom'; startPos: number; startSize: number } | null>(null);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!resizing.current) return;
const { side, startPos, startSize } = resizing.current;
if (side === 'left') {
const delta = e.clientX - startPos;
setLeftWidth(Math.max(200, Math.min(500, startSize + delta)));
} else if (side === 'right') {
const delta = startPos - e.clientX;
setRightWidth(Math.max(240, Math.min(600, startSize + delta)));
} else if (side === 'bottom') {
const delta = startPos - e.clientY;
setBottomHeight(Math.max(120, Math.min(500, startSize + delta)));
}
};
const onMouseUp = () => { resizing.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
}, []);
const startResize = useCallback((side: 'left' | 'right' | 'bottom', e: React.MouseEvent) => {
resizing.current = {
side,
startPos: side === 'bottom' ? e.clientY : e.clientX,
startSize: side === 'left' ? leftWidth : side === 'right' ? rightWidth : bottomHeight,
};
document.body.style.cursor = side === 'bottom' ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
}, [leftWidth, rightWidth, bottomHeight]);
// Persist workspace
useEffect(() => {
saveWorkspace({ leftOpen, rightOpen, bottomOpen, leftWidth, rightWidth, bottomHeight, mode, activityTab });
}, [leftOpen, rightOpen, bottomOpen, leftWidth, rightWidth, bottomHeight, mode, activityTab]);
// Toggles
const toggleLeft = useCallback(() => setLeftOpen((p: boolean) => !p), []);
const toggleRight = useCallback(() => setRightOpen((p: boolean) => !p), []);
const toggleBottom = useCallback(() => setBottomOpen((p: boolean) => !p), []);
const toggleCommandPalette = useCallback(() => setCommandPaletteOpen((p: boolean) => !p), []);
// Node operations
const nodeIdCounter = useRef(0);
const onDropComponent = useCallback((item: ComponentItem, position: { x: number; y: number }) => {
const newNode: Node = {
id: `node_${nodeIdCounter.current++}`,
type: 'transactionNode',
position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
};
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextNodes = [...t.nodes, newNode];
pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
}));
addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => {
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges);
pushHistory(tabId, t.nodes, nextEdges);
return { ...t, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`);
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
}, [setNodes]);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges((prev: Edge[]) => applyEdgeChanges(changes, prev));
}, [setEdges]);
const onSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: Node[] }) => {
setSelectedNodeIds(new Set(selectedNodes.map(n => n.id)));
}, []);
const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id));
const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
pushHistory(tabId, nextNodes, nextEdges);
return { ...t, nodes: nextNodes, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set());
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
const tabId = activeTransactionId;
setTransactionTabs(prev => prev.map(t => {
if (t.id !== tabId) return t;
const selected = t.nodes.filter(n => selectedNodeIds.has(n.id));
const newNodes = selected.map(n => ({
...n,
id: `node_${nodeIdCounter.current++}`,
position: { x: n.position.x + 40, y: n.position.y + 40 },
selected: false,
}));
const nextNodes = [...t.nodes, ...newNodes];
pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
}));
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate
const runValidation = useCallback(() => {
const issues: ValidationIssue[] = [];
if (nodes.length === 0) {
issues.push({ id: 'v1', severity: 'info', message: 'Graph is empty. Add components to begin validation.' });
} else {
const disconnected = nodes.filter(n => !edges.some(e => e.source === n.id || e.target === n.id));
disconnected.forEach(n => {
const d = n.data as Record<string, unknown>;
issues.push({ id: `v-disc-${n.id}`, severity: 'warning', node: (d.label as string) || n.id, message: 'Node is not connected to any other node' });
});
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
if (!hasCompliance) {
issues.push({ id: 'v-no-compliance', severity: 'warning', message: 'No compliance node in graph. Consider adding KYC/AML checks.' });
}
const sources = nodes.filter(n => !edges.some(e => e.target === n.id));
const sinks = nodes.filter(n => !edges.some(e => e.source === n.id));
if (sources.length === 0) issues.push({ id: 'v-no-source', severity: 'error', message: 'No source node found (node with no incoming edges)' });
if (sinks.length === 0) issues.push({ id: 'v-no-sink', severity: 'error', message: 'No terminal node found (node with no outgoing edges)' });
if (issues.filter(i => i.severity === 'error').length === 0) {
issues.push({ id: 'v-ok', severity: 'info', message: `Validation passed. ${nodes.length} nodes, ${edges.length} connections verified.` });
}
}
setValidationIssues(issues);
const errs = issues.filter(i => i.severity === 'error').length;
const warns = issues.filter(i => i.severity === 'warning').length;
addTerminalEntry(errs > 0 ? 'error' : warns > 0 ? 'warn' : 'success', 'validation', `Validation complete: ${errs} errors, ${warns} warnings`);
addAuditEntry('VALIDATION_RUN', `Validation: ${errs} errors, ${warns} warnings`);
// Update node statuses
setNodes(prev => prev.map(n => {
const nodeIssues = issues.filter(i => i.node === ((n.data as Record<string, unknown>).label as string));
const hasError = nodeIssues.some(i => i.severity === 'error');
const hasWarning = nodeIssues.some(i => i.severity === 'warning');
return {
...n,
data: { ...n.data, status: hasError ? 'error' : hasWarning ? 'warning' : (nodes.length > 0 ? 'valid' : undefined) },
};
}));
return issues;
}, [nodes, edges, addTerminalEntry, addAuditEntry, setNodes]);
// Simulate
const runSimulation = useCallback(() => {
if (nodes.length === 0) {
addTerminalEntry('warn', 'simulation', 'Cannot simulate empty graph');
return;
}
setIsSimulating(true);
addTerminalEntry('info', 'simulation', 'Starting transaction simulation...');
addAuditEntry('SIMULATION_START', 'Simulation initiated');
setTimeout(() => {
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
const routingNodes = nodes.filter(n => (n.data as Record<string, unknown>).category === 'routing');
const fee = (Math.random() * 0.1).toFixed(4);
const results = [
`Simulation complete for ${nodes.length} nodes, ${edges.length} edges`,
`Estimated fees: ${fee}%`,
`Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`,
`Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`,
`Routing: ${routingNodes.length} venue(s) evaluated`,
`Status: ${hasCompliance ? 'READY FOR EXECUTION' : 'REVIEW REQUIRED'}`,
].join('\n');
setSimulationResults(results);
setIsSimulating(false);
addTerminalEntry('success', 'simulation', 'Simulation completed successfully');
addAuditEntry('SIMULATION_COMPLETE', `Simulation: ${nodes.length} nodes processed`);
}, 1500);
}, [nodes, edges, addTerminalEntry, addAuditEntry]);
// Execute
const runExecution = useCallback(() => {
if (nodes.length === 0) {
addTerminalEntry('warn', 'execution', 'Cannot execute empty graph');
return;
}
if (mode !== 'Live') {
addTerminalEntry('info', 'execution', `Transaction submitted in ${mode} mode`);
} else {
addTerminalEntry('warn', 'execution', 'Live execution initiated — awaiting confirmation');
}
addAuditEntry('EXECUTE', `Transaction submitted in ${mode} mode`);
addTerminalEntry('success', 'execution', `Transaction ${activeTransaction.name} dispatched to settlement queue`);
}, [nodes, mode, activeTransaction.name, addTerminalEntry, addAuditEntry]);
// Transaction tab management
const addTransactionTab = useCallback(() => {
const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id);
dispatchHistory({ type: 'reset', tabId: id });
addTerminalEntry('info', 'system', 'New transaction tab created');
}, [addTerminalEntry]);
const closeTransactionTab = useCallback((id: string) => {
if (transactionTabs.length <= 1) return;
setTransactionTabs(prev => prev.filter(t => t.id !== id));
dispatchHistory({ type: 'remove', tabId: id });
if (activeTransactionId === id) {
const remaining = transactionTabs.filter(t => t.id !== id);
setActiveTransactionId(remaining[0].id);
}
}, [transactionTabs, activeTransactionId]);
const renameTransaction = useCallback((name: string) => {
setTransactionTabs(prev => prev.map(t => t.id === activeTransactionId ? { ...t, name } : t));
}, [activeTransactionId]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === 'k') { e.preventDefault(); toggleCommandPalette(); }
if (ctrl && e.key === 'b') { e.preventDefault(); toggleLeft(); }
if (ctrl && e.key === 'j') { e.preventDefault(); toggleRight(); }
if (ctrl && e.key === '`') { e.preventDefault(); toggleBottom(); }
if (ctrl && e.shiftKey && e.key === 'V') { e.preventDefault(); runValidation(); }
if (ctrl && e.shiftKey && e.key === 'S') { e.preventDefault(); runSimulation(); }
if (ctrl && e.shiftKey && e.key === 'E') { e.preventDefault(); runExecution(); }
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); redo(); }
if (ctrl && e.key === 'd') { e.preventDefault(); duplicateSelectedNodes(); }
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
if (selectedNodeIds.size > 0) { e.preventDefault(); deleteSelectedNodes(); }
}
if (e.key === 'Escape' && commandPaletteOpen) { setCommandPaletteOpen(false); }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [commandPaletteOpen, toggleCommandPalette, toggleLeft, toggleRight, toggleBottom, runValidation, runSimulation, runExecution, undo, redo, duplicateSelectedNodes, deleteSelectedNodes, selectedNodeIds]);
// Focus chat
const chatInputRef = useRef<HTMLInputElement>(null);
const focusChat = useCallback(() => { setRightOpen(true); setTimeout(() => chatInputRef.current?.focus(), 100); }, []);
const focusTerminal = useCallback(() => { setBottomOpen(true); }, []);
return (
<div className="app-shell">
<TitleBar
mode={mode}
onModeChange={setMode}
onToggleCommandPalette={toggleCommandPalette}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
/>
<div className="app-body">
<ActivityBar
activeTab={activityTab}
onTabChange={setActivityTab}
leftPanelOpen={leftOpen}
onToggleLeftPanel={toggleLeft}
/>
<div className="workspace">
<div className="workspace-upper">
{leftOpen && (
<LeftPanel
width={leftWidth}
activityTab={activityTab}
recentComponents={recentComponents}
/>
)}
{leftOpen && (
<div
className="panel-divider vertical"
onMouseDown={e => startResize('left', e)}
/>
)}
<div className="canvas-region">
<Canvas
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={onSelectionChange}
onDropComponent={onDropComponent}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
transactionName={activeTransaction.name}
onRenameTransaction={renameTransaction}
isSimulating={isSimulating}
simulationResults={simulationResults}
onDismissSimulation={() => setSimulationResults(null)}
mode={mode}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
onUndo={undo}
onRedo={redo}
selectedNodeIds={selectedNodeIds}
onDeleteSelected={deleteSelectedNodes}
onDuplicateSelected={duplicateSelectedNodes}
transactionTabs={transactionTabs}
activeTransactionId={activeTransactionId}
onSwitchTab={setActiveTransactionId}
onAddTab={addTransactionTab}
onCloseTab={closeTransactionTab}
splitView={splitView}
onToggleSplitView={() => setSplitView(p => !p)}
pushHistory={pushHistory}
/>
</div>
{rightOpen && (
<div
className="panel-divider vertical"
onMouseDown={e => startResize('right', e)}
/>
)}
{rightOpen && (
<RightPanel
width={rightWidth}
nodes={nodes}
edges={edges}
selectedNodes={selectedNodes}
chatInputRef={chatInputRef}
onInsertBlock={onDropComponent}
onRunValidation={runValidation}
onOptimizeRoute={() => {
addTerminalEntry('info', 'routing', 'Route optimization started...');
setTimeout(() => addTerminalEntry('success', 'routing', 'Optimal route found: Banking Rail → SWIFT Gateway (0.02% fee)'), 800);
}}
onRunCompliance={() => {
addTerminalEntry('info', 'compliance', 'Running compliance pass...');
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
setTimeout(() => addTerminalEntry(hasCompliance ? 'success' : 'warn', 'compliance', hasCompliance ? 'All compliance checks passed' : 'No compliance nodes found in graph'), 600);
}}
onGenerateSettlement={() => {
addTerminalEntry('info', 'settlement', 'Generating settlement message...');
setTimeout(() => addTerminalEntry('success', 'settlement', 'Settlement instruction generated: pacs.008 message ready'), 700);
}}
/>
)}
</div>
{bottomOpen && (
<div
className="panel-divider horizontal"
onMouseDown={e => startResize('bottom', e)}
/>
)}
{bottomOpen && (
<BottomPanel
height={bottomHeight}
isExpanded={bottomExpanded}
onToggleExpand={() => setBottomExpanded(p => !p)}
terminalEntries={terminalEntries}
auditEntries={auditEntries}
validationIssues={validationIssues}
/>
)}
</div>
</div>
<div className="status-bar">
<div className="status-bar-left">
<span className="status-item">
<span className="status-dot green" /> Connected
</span>
<span className="status-item">{mode}</span>
<span className="status-item">Multi-Jurisdiction</span>
</div>
<div className="status-bar-right">
<span className="status-item">Compliance: Active</span>
<span className="status-item">ISO-20022: Ready</span>
<span className="status-item">Routing: 12 venues</span>
<span className="status-item">
<kbd className="status-kbd">Ctrl+K</kbd> Command Palette
</span>
</div>
</div>
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onToggleLeft={toggleLeft}
onToggleRight={toggleRight}
onToggleBottom={toggleBottom}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
onNewTransaction={addTransactionTab}
onFocusChat={focusChat}
onFocusTerminal={focusTerminal}
onRunCompliance={() => {
addTerminalEntry('info', 'compliance', 'Running compliance pass...');
setTimeout(() => addTerminalEntry('success', 'compliance', 'Compliance pass completed'), 600);
}}
onOptimizeRoute={() => {
addTerminalEntry('info', 'routing', 'Optimizing routes...');
setTimeout(() => addTerminalEntry('success', 'routing', 'Routes optimized'), 600);
}}
onGenerateISO={() => {
addTerminalEntry('info', 'iso20022', 'Generating ISO-20022 message...');
setTimeout(() => addTerminalEntry('success', 'iso20022', 'pain.001 message generated'), 700);
}}
onExportAudit={() => {
addTerminalEntry('info', 'audit', 'Exporting audit summary...');
setTimeout(() => addTerminalEntry('success', 'audit', 'Audit summary exported'), 500);
}}
onSearchComponents={() => { setLeftOpen(true); setActivityTab('builder'); }}
/>
</div>
);
}

243
src/Portal.tsx Normal file
View File

@@ -0,0 +1,243 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import AccountsPage from './pages/AccountsPage';
import TreasuryPage from './pages/TreasuryPage';
import ReportingPage from './pages/ReportingPage';
import CompliancePage from './pages/CompliancePage';
import SettlementsPage from './pages/SettlementsPage';
import PortalLayout from './components/portal/PortalLayout';
import App from './App';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="portal-loading">
<div className="portal-loading-spinner" />
<span>Initializing secure session...</span>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function Portal() {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="portal-loading">
<div className="portal-loading-spinner" />
<span>Initializing secure session...</span>
</div>
);
}
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<PortalLayout>
<DashboardPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/transaction-builder"
element={
<ProtectedRoute>
<PortalLayout>
<div className="transaction-builder-module">
<App />
</div>
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/accounts"
element={
<ProtectedRoute>
<PortalLayout>
<AccountsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/treasury"
element={
<ProtectedRoute>
<PortalLayout>
<TreasuryPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/reporting"
element={
<ProtectedRoute>
<PortalLayout>
<ReportingPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/compliance"
element={
<ProtectedRoute>
<PortalLayout>
<CompliancePage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/settlements"
element={
<ProtectedRoute>
<PortalLayout>
<SettlementsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<PortalLayout>
<SettingsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
</Routes>
);
}
function SettingsPage() {
const { user, wallet } = useAuth();
return (
<div className="settings-page">
<div className="page-header">
<h1>Settings</h1>
<p className="page-subtitle">Portal configuration and user preferences</p>
</div>
<div className="settings-grid">
<div className="dashboard-card">
<div className="card-header"><h3>Profile</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Display Name</span>
<span className="setting-value">{user?.displayName || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Role</span>
<span className="setting-value">{user?.role?.replace('_', ' ') || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Institution</span>
<span className="setting-value">{user?.institution || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Department</span>
<span className="setting-value">{user?.department || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Wallet Address</span>
<span className="setting-value mono">{wallet?.address || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Chain ID</span>
<span className="setting-value">{wallet?.chainId || '—'}</span>
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Permissions</h3></div>
<div className="settings-section">
<div className="permissions-list">
{user?.permissions?.map(p => (
<span key={p} className="permission-badge">{p}</span>
)) || <span>No permissions</span>}
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Reporting Preferences</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Default Standard</span>
<span className="setting-value">IFRS</span>
</div>
<div className="setting-row">
<span className="setting-label">Base Currency</span>
<span className="setting-value">USD</span>
</div>
<div className="setting-row">
<span className="setting-label">Fiscal Year End</span>
<span className="setting-value">December 31</span>
</div>
<div className="setting-row">
<span className="setting-label">Auto-generate Reports</span>
<span className="setting-value">Monthly</span>
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Enterprise Controls</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Multi-signature Required</span>
<span className="setting-value">Yes (2-of-3)</span>
</div>
<div className="setting-row">
<span className="setting-label">Transaction Limit</span>
<span className="setting-value">$10,000,000</span>
</div>
<div className="setting-row">
<span className="setting-label">Approval Workflow</span>
<span className="setting-value">Dual Authorization</span>
</div>
<div className="setting-row">
<span className="setting-label">Session Timeout</span>
<span className="setting-value">30 minutes</span>
</div>
<div className="setting-row">
<span className="setting-label">Audit Logging</span>
<span className="setting-value">Enabled (Full)</span>
</div>
</div>
</div>
</div>
</div>
);
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,77 @@
import {
Blocks, Coins, LayoutTemplate, ShieldCheck, Route, Globe,
Bot, Terminal, History, Settings
} from 'lucide-react';
import type { ActivityTab } from '../types';
const tabs: { id: ActivityTab; icon: typeof Blocks; label: string }[] = [
{ id: 'builder', icon: Blocks, label: 'Builder' },
{ id: 'assets', icon: Coins, label: 'Assets' },
{ id: 'templates', icon: LayoutTemplate, label: 'Templates' },
{ id: 'compliance', icon: ShieldCheck, label: 'Compliance' },
{ id: 'routes', icon: Route, label: 'Routes' },
{ id: 'protocols', icon: Globe, label: 'Protocols' },
{ id: 'agents', icon: Bot, label: 'Agents' },
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
{ id: 'audit', icon: History, label: 'Audit' },
{ id: 'settings', icon: Settings, label: 'Settings' },
];
interface ActivityBarProps {
activeTab: ActivityTab;
onTabChange: (tab: ActivityTab) => void;
leftPanelOpen: boolean;
onToggleLeftPanel: () => void;
}
export default function ActivityBar({ activeTab, onTabChange, leftPanelOpen, onToggleLeftPanel }: ActivityBarProps) {
return (
<div className="activity-bar">
<div className="activity-bar-top">
{tabs.slice(0, 7).map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
className={`activity-btn ${isActive && leftPanelOpen ? 'active' : ''}`}
title={tab.label}
onClick={() => {
if (isActive && leftPanelOpen) {
onToggleLeftPanel();
} else {
onTabChange(tab.id);
if (!leftPanelOpen) onToggleLeftPanel();
}
}}
>
<Icon size={20} />
</button>
);
})}
</div>
<div className="activity-bar-bottom">
{tabs.slice(7).map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={`activity-btn ${activeTab === tab.id && leftPanelOpen ? 'active' : ''}`}
title={tab.label}
onClick={() => {
if (activeTab === tab.id && leftPanelOpen) {
onToggleLeftPanel();
} else {
onTabChange(tab.id);
if (!leftPanelOpen) onToggleLeftPanel();
}
}}
>
<Icon size={20} />
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
import { useState } from 'react';
import {
Terminal, ShieldCheck, Radio, History, Mail, Activity, Maximize2, Minimize2,
Search, Download, Filter, AlertOctagon, GitCompare
} from 'lucide-react';
import type { BottomTab, TerminalEntry, AuditEntry, ValidationIssue } from '../types';
import { sampleTerminal, sampleValidation, sampleAudit, sampleSettlement, sampleReconciliation, sampleExceptions, sampleMessageQueue, sampleEvents } from '../data/sampleData';
const tabs: { id: BottomTab; icon: typeof Terminal; label: string }[] = [
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
{ id: 'validation', icon: ShieldCheck, label: 'Validation' },
{ id: '800system', icon: Radio, label: '800 System' },
{ id: 'settlement', icon: Activity, label: 'Settlement Queue' },
{ id: 'audit', icon: History, label: 'Audit Trail' },
{ id: 'messages', icon: Mail, label: 'Messages' },
{ id: 'events', icon: Activity, label: 'Events' },
{ id: 'reconciliation', icon: GitCompare, label: 'Reconciliation' },
{ id: 'exceptions', icon: AlertOctagon, label: 'Exceptions' },
];
interface BottomPanelProps {
height: number;
isExpanded: boolean;
onToggleExpand: () => void;
terminalEntries: TerminalEntry[];
auditEntries: AuditEntry[];
validationIssues: ValidationIssue[];
}
const statusColors: Record<string, string> = {
settled: '#22c55e',
pending: '#eab308',
in_review: '#3b82f6',
awaiting_approval: '#f97316',
dispatched: '#3b82f6',
partially_settled: '#a855f7',
failed: '#ef4444',
};
const levelColors: Record<string, string> = {
info: '#6b7280',
warn: '#eab308',
error: '#ef4444',
success: '#22c55e',
};
export default function BottomPanel({ height, isExpanded, onToggleExpand, terminalEntries, auditEntries, validationIssues }: BottomPanelProps) {
const [activeTab, setActiveTab] = useState<BottomTab>('terminal');
const [terminalFilter, setTerminalFilter] = useState('');
const [bottomSearch, setBottomSearch] = useState('');
const [showSearchBar, setShowSearchBar] = useState(false);
const [showFilterBar, setShowFilterBar] = useState(false);
const [levelFilter, setLevelFilter] = useState<string>('all');
const allTerminal = [...sampleTerminal, ...terminalEntries];
const filteredTerminal = allTerminal.filter(e => {
const matchesText = e.message.toLowerCase().includes(terminalFilter.toLowerCase()) ||
e.source.toLowerCase().includes(terminalFilter.toLowerCase());
const matchesLevel = levelFilter === 'all' || e.level === levelFilter;
return matchesText && matchesLevel;
});
const allAudit = [...sampleAudit, ...auditEntries];
const allValidation = validationIssues.length > 0 ? validationIssues : sampleValidation;
const handleExport = () => {
let content = '';
if (activeTab === 'terminal') {
content = allTerminal.map(e => `[${e.timestamp.toISOString()}] [${e.level}] [${e.source}] ${e.message}`).join('\n');
} else if (activeTab === 'audit') {
content = allAudit.map(e => `[${e.timestamp.toISOString()}] ${e.user} ${e.action}: ${e.detail}`).join('\n');
} else if (activeTab === 'validation') {
content = allValidation.map(e => `[${e.severity}] ${e.node ? e.node + ': ' : ''}${e.message}`).join('\n');
} else {
content = `Export of ${activeTab} tab data`;
}
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transactflow-${activeTab}-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="bottom-panel" style={{ height: isExpanded ? '50vh' : height }}>
<div className="bottom-panel-header">
<div className="bottom-panel-tabs">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={`bottom-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<Icon size={13} />
<span>{tab.label}</span>
{tab.id === 'validation' && (
<span className="tab-badge info">{allValidation.length}</span>
)}
{tab.id === 'settlement' && (
<span className="tab-badge warn">{sampleSettlement.filter(s => s.status === 'pending').length}</span>
)}
{tab.id === 'exceptions' && (
<span className="tab-badge error">{sampleExceptions.length}</span>
)}
</button>
);
})}
</div>
<div className="bottom-panel-actions">
<button className="icon-btn-sm" title="Filter" onClick={() => { setShowFilterBar(!showFilterBar); setShowSearchBar(false); }}>
<Filter size={13} />
</button>
<button className="icon-btn-sm" title="Search" onClick={() => { setShowSearchBar(!showSearchBar); setShowFilterBar(false); }}>
<Search size={13} />
</button>
<button className="icon-btn-sm" title="Export" onClick={handleExport}>
<Download size={13} />
</button>
<button className="icon-btn-sm" title={isExpanded ? 'Minimize' : 'Maximize'} onClick={onToggleExpand}>
{isExpanded ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
</button>
</div>
</div>
{showSearchBar && (
<div className="bottom-search-bar">
<Search size={12} />
<input
type="text"
placeholder="Search in panel..."
value={bottomSearch}
onChange={e => setBottomSearch(e.target.value)}
autoFocus
/>
</div>
)}
{showFilterBar && activeTab === 'terminal' && (
<div className="bottom-filter-bar">
{['all', 'info', 'warn', 'error', 'success'].map(level => (
<button
key={level}
className={`filter-pill ${levelFilter === level ? 'active' : ''}`}
onClick={() => setLevelFilter(level)}
>
{level === 'all' ? 'All' : level.toUpperCase()}
</button>
))}
</div>
)}
<div className="bottom-panel-content">
{activeTab === 'terminal' && (
<div className="terminal-content">
<div className="terminal-filter">
<Search size={12} />
<input
type="text"
placeholder="Filter logs..."
value={terminalFilter}
onChange={e => setTerminalFilter(e.target.value)}
/>
</div>
<div className="terminal-entries">
{filteredTerminal.map(entry => (
<div key={entry.id} className="terminal-entry">
<span className="terminal-time">
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="terminal-level" style={{ color: levelColors[entry.level] }}>
[{entry.level.toUpperCase()}]
</span>
<span className="terminal-source">[{entry.source}]</span>
<span className="terminal-msg">{entry.message}</span>
</div>
))}
<div className="terminal-cursor">
<span className="cursor-blink"></span>
</div>
</div>
</div>
)}
{activeTab === 'validation' && (
<div className="validation-content">
{allValidation.map(issue => (
<div key={issue.id} className={`validation-entry ${issue.severity}`}>
<span className="validation-severity">{issue.severity.toUpperCase()}</span>
{issue.node && <span className="validation-node">{issue.node}</span>}
<span className="validation-msg">{issue.message}</span>
</div>
))}
</div>
)}
{activeTab === '800system' && (
<div className="system-800-content">
<div className="system-800-grid">
<div className="system-800-card">
<div className="system-800-card-header">Message Queue</div>
<div className="system-800-card-value">0 pending</div>
<div className="system-800-card-status healthy">Healthy</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Core Banking</div>
<div className="system-800-card-value">Connected</div>
<div className="system-800-card-status healthy">Online</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Ledger Feed</div>
<div className="system-800-card-value">0 postings/s</div>
<div className="system-800-card-status idle">Idle</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">SWIFT Gateway</div>
<div className="system-800-card-value">Ready</div>
<div className="system-800-card-status healthy">Online</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">ISO-20022 Engine</div>
<div className="system-800-card-value">3 schemas</div>
<div className="system-800-card-status healthy">Loaded</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Retry Queue</div>
<div className="system-800-card-value">0 items</div>
<div className="system-800-card-status healthy">Clear</div>
</div>
</div>
</div>
)}
{activeTab === 'settlement' && (
<div className="settlement-content">
<table className="settlement-table">
<thead>
<tr>
<th>TX ID</th>
<th>Status</th>
<th>Amount</th>
<th>Asset</th>
<th>Counterparty</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{sampleSettlement.map(item => (
<tr key={item.id}>
<td className="mono">{item.txId}</td>
<td>
<span className="status-badge" style={{ color: statusColors[item.status], borderColor: statusColors[item.status] + '40' }}>
{item.status.replace(/_/g, ' ')}
</span>
</td>
<td className="mono">{item.amount}</td>
<td>{item.asset}</td>
<td>{item.counterparty}</td>
<td className="mono">{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'audit' && (
<div className="audit-content">
{allAudit.map(entry => (
<div key={entry.id} className="audit-entry">
<span className="audit-time">
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="audit-user">{entry.user}</span>
<span className="audit-action">{entry.action}</span>
<span className="audit-detail">{entry.detail}</span>
</div>
))}
</div>
)}
{activeTab === 'messages' && (
<div className="messages-content">
<table className="settlement-table">
<thead>
<tr>
<th>Type</th>
<th>Direction</th>
<th>Counterparty</th>
<th>Status</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{sampleMessageQueue.map(msg => (
<tr key={msg.id}>
<td className="mono">{msg.msgType}</td>
<td>
<span className={`direction-badge ${msg.direction}`}>
{msg.direction === 'inbound' ? '← IN' : '→ OUT'}
</span>
</td>
<td>{msg.counterparty}</td>
<td>
<span className="status-badge" style={{ color: msg.status === 'sent' ? '#22c55e' : msg.status === 'received' ? '#3b82f6' : '#eab308' }}>
{msg.status}
</span>
</td>
<td className="mono">{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'events' && (
<div className="events-content">
{sampleEvents.map(evt => (
<div key={evt.id} className="audit-entry">
<span className="audit-time">
{evt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="audit-action">{evt.type}</span>
<span className="audit-detail">{evt.detail}</span>
</div>
))}
</div>
)}
{activeTab === 'reconciliation' && (
<div className="reconciliation-content">
<table className="settlement-table">
<thead>
<tr>
<th>TX ID</th>
<th>Internal Ref</th>
<th>External Ref</th>
<th>Status</th>
<th>Amount</th>
<th>Asset</th>
</tr>
</thead>
<tbody>
{sampleReconciliation.map(item => (
<tr key={item.id}>
<td className="mono">{item.txId}</td>
<td className="mono">{item.internalRef}</td>
<td className="mono">{item.externalRef}</td>
<td>
<span className="status-badge" style={{ color: item.status === 'matched' ? '#22c55e' : '#ef4444' }}>
{item.status}
</span>
</td>
<td className="mono">{item.amount}</td>
<td>{item.asset}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'exceptions' && (
<div className="exceptions-content">
{sampleExceptions.map(exc => (
<div key={exc.id} className={`validation-entry ${exc.severity}`}>
<span className="validation-severity">{exc.severity.toUpperCase()}</span>
<span className="validation-node">{exc.txId}</span>
<span className="exception-type">{exc.type}</span>
<span className="validation-msg">{exc.message}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

353
src/components/Canvas.tsx Normal file
View File

@@ -0,0 +1,353 @@
import { useCallback, useRef, useState, type DragEvent } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Connection,
type Node,
type Edge,
BackgroundVariant,
type OnNodesChange,
type OnEdgesChange,
useReactFlow,
ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import TransactionNodeComponent from './TransactionNode';
import {
Save, GitBranch, ShieldCheck, FlaskConical, Play,
AlertTriangle, CheckCircle2, DollarSign, Clock, Globe,
Undo2, Redo2, Copy, Trash2, Plus, X, SplitSquareHorizontal,
ZoomIn, ZoomOut, Maximize
} from 'lucide-react';
import type { ComponentItem, TransactionTab, SessionMode } from '../types';
const nodeTypes = { transactionNode: TransactionNodeComponent };
interface CanvasProps {
nodes: Node[];
edges: Edge[];
setNodes: (updater: Node[] | ((prev: Node[]) => Node[])) => void;
setEdges: (updater: Edge[] | ((prev: Edge[]) => Edge[])) => void;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: (params: Connection) => void;
onSelectionChange: (params: { nodes: Node[] }) => void;
onDropComponent: (item: ComponentItem, position: { x: number; y: number }) => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
transactionName: string;
onRenameTransaction: (name: string) => void;
isSimulating: boolean;
simulationResults: string | null;
onDismissSimulation: () => void;
mode: SessionMode;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
selectedNodeIds: Set<string>;
onDeleteSelected: () => void;
onDuplicateSelected: () => void;
transactionTabs: TransactionTab[];
activeTransactionId: string;
onSwitchTab: (id: string) => void;
onAddTab: () => void;
onCloseTab: (id: string) => void;
splitView: boolean;
onToggleSplitView: () => void;
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
}
function CanvasInner({
nodes, edges,
onNodesChange, onEdgesChange, onConnect, onSelectionChange, onDropComponent,
onValidate, onSimulate, onExecute,
transactionName, onRenameTransaction,
isSimulating, simulationResults, onDismissSimulation,
mode, canUndo, canRedo, onUndo, onRedo,
selectedNodeIds, onDeleteSelected, onDuplicateSelected,
transactionTabs, activeTransactionId, onSwitchTab, onAddTab, onCloseTab,
splitView, onToggleSplitView,
}: CanvasProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(transactionName);
const [zoomLevel, setZoomLevel] = useState(100);
const reactFlowInstance = useReactFlow();
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const data = event.dataTransfer.getData('application/transactflow-component');
if (!data) return;
const item: ComponentItem = JSON.parse(data);
const wrapperBounds = reactFlowWrapper.current?.getBoundingClientRect();
if (!wrapperBounds) return;
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
onDropComponent(item, position);
},
[onDropComponent, reactFlowInstance]
);
const onMoveEnd = useCallback(() => {
const zoom = reactFlowInstance.getZoom();
setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); };
const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;
const warningCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'warning').length;
const commitName = () => {
setIsEditingName(false);
if (editName.trim()) onRenameTransaction(editName.trim());
else setEditName(transactionName);
};
return (
<div className="canvas-container">
{/* Transaction tabs */}
<div className="transaction-tabs">
{transactionTabs.map(tab => (
<div
key={tab.id}
className={`transaction-tab ${tab.id === activeTransactionId ? 'active' : ''}`}
onClick={() => onSwitchTab(tab.id)}
>
<span>{tab.name}</span>
{transactionTabs.length > 1 && (
<button className="tab-close" onClick={e => { e.stopPropagation(); onCloseTab(tab.id); }}>
<X size={10} />
</button>
)}
</div>
))}
<button className="transaction-tab-add" onClick={onAddTab} title="New Transaction">
<Plus size={12} />
</button>
</div>
<div className="canvas-header">
<div className="canvas-header-left">
{isEditingName ? (
<input
className="canvas-tx-name-input"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') { setEditName(transactionName); setIsEditingName(false); } }}
autoFocus
/>
) : (
<span
className="canvas-tx-name"
onClick={() => { setIsEditingName(true); setEditName(transactionName); }}
title="Click to rename"
>
{transactionName}
</span>
)}
<span className="canvas-version">v1.0</span>
<span className="canvas-save-state">
<Save size={12} /> Saved
</span>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
<Undo2 size={14} />
</button>
<button className="canvas-toolbar-btn" onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Y)">
<Redo2 size={14} />
</button>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onDuplicateSelected} disabled={selectedNodeIds.size === 0} title="Duplicate (Ctrl+D)">
<Copy size={14} />
</button>
<button className="canvas-toolbar-btn" onClick={onDeleteSelected} disabled={selectedNodeIds.size === 0} title="Delete (Del)">
<Trash2 size={14} />
</button>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onToggleSplitView} title="Split View">
<SplitSquareHorizontal size={14} />
</button>
</div>
<div className="canvas-header-center">
<button className="canvas-env-btn">
<GitBranch size={13} />
<span>{mode}</span>
</button>
</div>
<div className="canvas-header-right">
<button className="canvas-action-btn validate" onClick={onValidate}>
<ShieldCheck size={14} /> Validate
</button>
<button className="canvas-action-btn simulate" onClick={onSimulate} disabled={isSimulating}>
<FlaskConical size={14} /> {isSimulating ? 'Simulating...' : 'Simulate'}
</button>
<button className="canvas-action-btn execute" onClick={onExecute}>
<Play size={14} /> Execute
</button>
</div>
</div>
<div className="canvas-body" ref={reactFlowWrapper}>
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
<div style={{ flex: 1, position: 'relative' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={onSelectionChange}
onDragOver={onDragOver}
onDrop={onDrop}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
fitView
snapToGrid
snapGrid={[16, 16]}
multiSelectionKeyCode="Shift"
deleteKeyCode={null}
defaultEdgeOptions={{
animated: true,
style: { stroke: '#3b82f6', strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#333" />
<Controls className="canvas-controls" showZoom={false} showFitView={false} showInteractive={false}>
<button className="react-flow__controls-button" onClick={handleZoomIn} title="Zoom In">
<ZoomIn size={14} />
</button>
<button className="react-flow__controls-button" onClick={handleZoomOut} title="Zoom Out">
<ZoomOut size={14} />
</button>
<button className="react-flow__controls-button zoom-display" title="Current Zoom">
{zoomLevel}%
</button>
<button className="react-flow__controls-button" onClick={handleFitView} title="Fit View">
<Maximize size={14} />
</button>
</Controls>
<MiniMap
className="canvas-minimap"
nodeColor={(n) => {
const d = n.data as Record<string, unknown>;
return (d?.color as string) || '#3b82f6';
}}
maskColor="rgba(0,0,0,0.7)"
/>
</ReactFlow>
</div>
{splitView && (
<>
<div style={{ width: 1, background: '#2a2a32', flexShrink: 0 }} />
<div style={{ flex: 1, position: 'relative', background: '#0e0e10', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#5c5c68' }}>
<SplitSquareHorizontal size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
<p style={{ fontSize: 13 }}>Comparison View</p>
<p style={{ fontSize: 11, marginTop: 4 }}>Select a saved version or branch to compare</p>
</div>
</div>
</>
)}
</div>
{nodes.length === 0 && !splitView && (
<div className="canvas-empty">
<div className="canvas-empty-content">
<div className="canvas-empty-icon"></div>
<h3>Start Building</h3>
<p>Drag components from the left panel onto the canvas to compose your transaction flow</p>
<p className="canvas-empty-hint">or press <kbd>Ctrl+K</kbd> to search components</p>
</div>
</div>
)}
{/* Simulation overlay */}
{isSimulating && (
<div className="simulation-overlay">
<div className="simulation-spinner" />
<span>Running simulation...</span>
</div>
)}
{simulationResults && (
<div className="simulation-results-overlay">
<div className="simulation-results-card">
<div className="simulation-results-header">
<CheckCircle2 size={16} color="#22c55e" />
<span>Simulation Results</span>
<button className="simulation-dismiss" onClick={onDismissSimulation}><X size={14} /></button>
</div>
<pre className="simulation-results-body">{simulationResults}</pre>
</div>
</div>
)}
</div>
<div className="canvas-inspector">
<div className="inspector-item">
<CheckCircle2 size={12} color="#22c55e" />
<span>{nodes.length} nodes</span>
</div>
<div className="inspector-item">
<span>{edges.length} connections</span>
</div>
<div className="inspector-separator" />
<div className="inspector-item">
<AlertTriangle size={12} color={errorCount > 0 ? '#ef4444' : '#555'} />
<span>{errorCount} errors</span>
</div>
<div className="inspector-item">
<AlertTriangle size={12} color={warningCount > 0 ? '#eab308' : '#555'} />
<span>{warningCount} warnings</span>
</div>
<div className="inspector-separator" />
<div className="inspector-item">
<DollarSign size={12} />
<span>Est. fees: {nodes.length > 0 ? '0.02%' : '—'}</span>
</div>
<div className="inspector-item">
<Clock size={12} />
<span>Settlement: {nodes.length > 0 ? 'T+1' : '—'}</span>
</div>
<div className="inspector-item">
<Globe size={12} />
<span>Jurisdictions: {nodes.length > 0 ? 'Multi' : '—'}</span>
</div>
{selectedNodeIds.size > 0 && (
<>
<div className="inspector-separator" />
<div className="inspector-item selected-info">
<span>{selectedNodeIds.size} selected</span>
</div>
</>
)}
</div>
</div>
);
}
export default function Canvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,168 @@
import { useState, useEffect, useRef } from 'react';
import { Search, ArrowRight } from 'lucide-react';
interface Command {
id: string;
label: string;
category: string;
shortcut?: string;
}
const commands: Command[] = [
{ id: 'validate', label: 'Run Validation', category: 'Actions', shortcut: 'Ctrl+Shift+V' },
{ id: 'simulate', label: 'Run Simulation', category: 'Actions', shortcut: 'Ctrl+Shift+S' },
{ id: 'execute', label: 'Execute Transaction', category: 'Actions', shortcut: 'Ctrl+Shift+E' },
{ id: 'toggle-left', label: 'Toggle Left Panel', category: 'View', shortcut: 'Ctrl+B' },
{ id: 'toggle-right', label: 'Toggle Right Panel', category: 'View', shortcut: 'Ctrl+J' },
{ id: 'toggle-bottom', label: 'Toggle Bottom Panel', category: 'View', shortcut: 'Ctrl+`' },
{ id: 'search-components', label: 'Search Components', category: 'Navigation' },
{ id: 'new-transaction', label: 'New Transaction', category: 'File', shortcut: 'Ctrl+N' },
{ id: 'save', label: 'Save Transaction', category: 'File', shortcut: 'Ctrl+S' },
{ id: 'export', label: 'Export Transaction', category: 'File' },
{ id: 'import-template', label: 'Import Template', category: 'File' },
{ id: 'focus-chat', label: 'Focus Chat Panel', category: 'Navigation', shortcut: 'Ctrl+/' },
{ id: 'focus-terminal', label: 'Focus Terminal', category: 'Navigation' },
{ id: 'compliance-pass', label: 'Run Compliance Pass', category: 'Compliance' },
{ id: 'optimize-route', label: 'Optimize Routes', category: 'Routing' },
{ id: 'gen-iso', label: 'Generate ISO-20022 Message', category: 'Messaging' },
{ id: 'audit-export', label: 'Export Audit Summary', category: 'Audit' },
];
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleBottom: () => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
onNewTransaction: () => void;
onFocusChat: () => void;
onFocusTerminal: () => void;
onRunCompliance: () => void;
onOptimizeRoute: () => void;
onGenerateISO: () => void;
onExportAudit: () => void;
onSearchComponents: () => void;
}
export default function CommandPalette({
isOpen, onClose,
onToggleLeft, onToggleRight, onToggleBottom,
onValidate, onSimulate, onExecute,
onNewTransaction, onFocusChat, onFocusTerminal,
onRunCompliance, onOptimizeRoute, onGenerateISO, onExportAudit,
onSearchComponents,
}: CommandPaletteProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const filtered = commands.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()) ||
c.category.toLowerCase().includes(query.toLowerCase())
);
const grouped = filtered.reduce<Record<string, Command[]>>((acc, cmd) => {
if (!acc[cmd.category]) acc[cmd.category] = [];
acc[cmd.category].push(cmd);
return acc;
}, {});
const flatList = Object.values(grouped).flat();
const executeCommand = (id: string) => {
switch (id) {
case 'toggle-left': onToggleLeft(); break;
case 'toggle-right': onToggleRight(); break;
case 'toggle-bottom': onToggleBottom(); break;
case 'validate': onValidate(); break;
case 'simulate': onSimulate(); break;
case 'execute': onExecute(); break;
case 'new-transaction': onNewTransaction(); break;
case 'focus-chat': onFocusChat(); break;
case 'focus-terminal': onFocusTerminal(); break;
case 'compliance-pass': onRunCompliance(); break;
case 'optimize-route': onOptimizeRoute(); break;
case 'gen-iso': onGenerateISO(); break;
case 'audit-export': onExportAudit(); break;
case 'search-components': onSearchComponents(); break;
case 'save': /* already auto-saved */ break;
case 'export': /* export handled */ break;
case 'import-template': /* import handled */ break;
}
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'Enter' && flatList.length > 0) {
executeCommand(flatList[selectedIndex]?.id || flatList[0].id);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, flatList.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
}
};
let runningIndex = 0;
return (
<div className="command-palette-overlay" onClick={onClose}>
<div className="command-palette" onClick={e => e.stopPropagation()}>
<div className="command-palette-input">
<Search size={16} />
<input
ref={inputRef}
type="text"
placeholder="Type a command or search..."
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
/>
</div>
<div className="command-palette-results">
{Object.entries(grouped).map(([category, cmds]) => (
<div key={category} className="command-group">
<div className="command-group-header">{category}</div>
{cmds.map(cmd => {
const idx = runningIndex++;
return (
<div
key={cmd.id}
className={`command-item ${idx === selectedIndex ? 'selected' : ''}`}
onClick={() => executeCommand(cmd.id)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<ArrowRight size={12} />
<span className="command-label">{cmd.label}</span>
{cmd.shortcut && <kbd className="command-shortcut">{cmd.shortcut}</kbd>}
</div>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="command-empty">No commands found</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useState, type DragEvent } from 'react';
import { Search, Star, Clock, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
import { componentCategories, componentItems } from '../data/components';
import type { ComponentItem, ActivityTab } from '../types';
interface LeftPanelProps {
width: number;
activityTab: ActivityTab;
recentComponents: string[];
}
const activityTabLabels: Record<ActivityTab, string> = {
builder: 'Components',
assets: 'Assets',
templates: 'Templates',
compliance: 'Compliance',
routes: 'Routes',
protocols: 'Protocols',
agents: 'Agents',
terminal: 'Terminal',
audit: 'Audit',
settings: 'Settings',
};
const activityTabCategories: Partial<Record<ActivityTab, string[]>> = {
assets: ['assets'],
templates: ['templates'],
compliance: ['compliance'],
routes: ['routing'],
protocols: ['messaging'],
};
export default function LeftPanel({ width, activityTab, recentComponents }: LeftPanelProps) {
const [search, setSearch] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(componentCategories.map(c => c.id))
);
const [favorites, setFavorites] = useState<Set<string>>(new Set(['transfer', 'swap', 'kyc']));
const [activeFilter, setActiveFilter] = useState<'all' | 'favorites' | 'recent'>('all');
const [tooltipItem, setTooltipItem] = useState<ComponentItem | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const toggleCategory = (id: string) => {
const next = new Set(expandedCategories);
if (next.has(id)) next.delete(id); else next.add(id);
setExpandedCategories(next);
};
const toggleFavorite = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const next = new Set(favorites);
if (next.has(id)) next.delete(id); else next.add(id);
setFavorites(next);
};
const onDragStart = (e: DragEvent, item: ComponentItem) => {
e.dataTransfer.setData('application/transactflow-component', JSON.stringify(item));
e.dataTransfer.effectAllowed = 'move';
// Create drag preview
const preview = document.createElement('div');
preview.className = 'drag-preview';
preview.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;
preview.style.cssText = 'position:fixed;top:-100px;left:-100px;background:#1a1a20;border:1px solid #3b82f6;border-radius:6px;padding:6px 12px;color:#e4e4e8;font-size:12px;display:flex;align-items:center;gap:6px;z-index:10000;pointer-events:none;';
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 0, 0);
setTimeout(() => document.body.removeChild(preview), 0);
};
const showTooltip = (item: ComponentItem, e: React.MouseEvent) => {
setTooltipItem(item);
setTooltipPos({ x: e.clientX + 12, y: e.clientY - 10 });
};
const hideTooltip = () => setTooltipItem(null);
// For non-builder tabs, show filtered content
if (activityTab !== 'builder') {
const categoryFilter = activityTabCategories[activityTab];
if (activityTab === 'settings') {
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-content">
<div className="settings-panel">
<div className="settings-group">
<div className="settings-group-header">Workspace</div>
<div className="settings-item"><span>Theme</span><span className="settings-value">Dark</span></div>
<div className="settings-item"><span>Font Size</span><span className="settings-value">13px</span></div>
<div className="settings-item"><span>Snap to Grid</span><span className="settings-value">Enabled</span></div>
<div className="settings-item"><span>Grid Size</span><span className="settings-value">16px</span></div>
</div>
<div className="settings-group">
<div className="settings-group-header">Canvas</div>
<div className="settings-item"><span>Auto-save</span><span className="settings-value">On</span></div>
<div className="settings-item"><span>Minimap</span><span className="settings-value">Visible</span></div>
<div className="settings-item"><span>Animations</span><span className="settings-value">Enabled</span></div>
</div>
<div className="settings-group">
<div className="settings-group-header">Compliance</div>
<div className="settings-item"><span>Auto-validate</span><span className="settings-value">Off</span></div>
<div className="settings-item"><span>Jurisdiction</span><span className="settings-value">Multi</span></div>
</div>
</div>
</div>
</div>
);
}
if (activityTab === 'terminal' || activityTab === 'audit') {
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-content">
<div className="empty-state">
<p>{activityTab === 'terminal' ? 'Terminal output is shown in the bottom panel.' : 'Audit trail is shown in the bottom panel.'}</p>
<p style={{ marginTop: 8, fontSize: 11 }}>Use <kbd style={{ background: '#1a1a20', border: '1px solid #2a2a32', borderRadius: 3, padding: '1px 4px', fontSize: 10 }}>Ctrl+`</kbd> to toggle the bottom panel.</p>
</div>
</div>
</div>
);
}
if (activityTab === 'agents') {
const agentList = [
{ name: 'Builder Agent', desc: 'Helps construct transaction flows', color: '#3b82f6' },
{ name: 'Compliance Agent', desc: 'Monitors policy violations', color: '#22c55e' },
{ name: 'Routing Agent', desc: 'Optimizes execution paths', color: '#f97316' },
{ name: 'ISO-20022 Agent', desc: 'Generates messaging payloads', color: '#a855f7' },
{ name: 'Settlement Agent', desc: 'Manages settlement instructions', color: '#eab308' },
{ name: 'Risk Agent', desc: 'Evaluates transaction risk', color: '#ef4444' },
{ name: 'Documentation Agent', desc: 'Generates deal memos', color: '#6b7280' },
];
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">Agents</span></div>
<div className="left-panel-content">
{agentList.map(a => (
<div key={a.name} className="agent-list-item">
<div className="agent-list-dot" style={{ background: a.color }} />
<div>
<div className="agent-list-name">{a.name}</div>
<div className="agent-list-desc">{a.desc}</div>
</div>
</div>
))}
</div>
</div>
);
}
// For assets, templates, compliance, routes, protocols: show filtered components
const filteredItems = categoryFilter
? componentItems.filter(i => categoryFilter.includes(i.category))
: componentItems;
const searchFiltered = filteredItems.filter(item =>
item.label.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-search">
<Search size={14} className="search-icon" />
<input
type="text"
placeholder={`Search ${activityTabLabels[activityTab].toLowerCase()}...`}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="left-panel-content">
<div className="category-items flat">
{searchFiltered.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
{searchFiltered.length === 0 && <div className="empty-state">No items found</div>}
</div>
</div>
{tooltipItem && (
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
<div className="tooltip-desc">{tooltipItem.description}</div>
<div className="tooltip-meta">
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
</span>
</div>
{tooltipItem.inputs && <div className="tooltip-fields"><strong>Inputs:</strong> {tooltipItem.inputs.join(', ')}</div>}
{tooltipItem.outputs && <div className="tooltip-fields"><strong>Outputs:</strong> {tooltipItem.outputs.join(', ')}</div>}
</div>
)}
</div>
);
}
// Builder tab: full component library
const filtered = componentItems.filter(item =>
item.label.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
);
const displayItems = activeFilter === 'favorites'
? filtered.filter(i => favorites.has(i.id))
: activeFilter === 'recent'
? filtered.filter(i => recentComponents.includes(i.id)).sort((a, b) => recentComponents.indexOf(a.id) - recentComponents.indexOf(b.id))
: filtered;
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header">
<span className="panel-title">Components</span>
</div>
<div className="left-panel-search">
<Search size={14} className="search-icon" />
<input
type="text"
placeholder="Search components..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="left-panel-filters">
<button className={`filter-btn ${activeFilter === 'all' ? 'active' : ''}`} onClick={() => setActiveFilter('all')}>All</button>
<button className={`filter-btn ${activeFilter === 'favorites' ? 'active' : ''}`} onClick={() => setActiveFilter('favorites')}>
<Star size={11} /> Favorites
</button>
<button className={`filter-btn ${activeFilter === 'recent' ? 'active' : ''}`} onClick={() => setActiveFilter('recent')}>
<Clock size={11} /> Recent
</button>
</div>
<div className="left-panel-content">
{activeFilter === 'all' && !search ? (
componentCategories.map(cat => {
const catItems = displayItems.filter(i => i.category === cat.id);
if (catItems.length === 0) return null;
const isExpanded = expandedCategories.has(cat.id);
return (
<div key={cat.id} className="component-category">
<div className="category-header" onClick={() => toggleCategory(cat.id)}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="category-icon">{cat.icon}</span>
<span className="category-label">{cat.label}</span>
<span className="category-count">{catItems.length}</span>
</div>
{isExpanded && (
<div className="category-items">
{catItems.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
</div>
)}
</div>
);
})
) : (
<div className="category-items flat">
{displayItems.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<span className="component-category-badge">
{componentCategories.find(c => c.id === item.category)?.label}
</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
{displayItems.length === 0 && (
<div className="empty-state">
{activeFilter === 'recent' ? 'No recently used components. Drag a component to the canvas to see it here.' : 'No matching components found.'}
</div>
)}
</div>
)}
</div>
{tooltipItem && (
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
<div className="tooltip-desc">{tooltipItem.description}</div>
<div className="tooltip-meta">
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,370 @@
import { useState, useRef, useEffect } from 'react';
import {
Send, Sparkles, Wrench, ShieldCheck, Route, FileText,
Landmark, AlertTriangle, BookOpen, ChevronDown, Plus,
Zap, RefreshCw, FileOutput, MessageSquare,
History, Target
} from 'lucide-react';
import type { Agent, ChatMessage, ConversationScope, ComponentItem } from '../types';
import { sampleMessages, sampleThreads } from '../data/sampleData';
import { componentItems } from '../data/components';
import type { Node, Edge } from '@xyflow/react';
const agents: { id: Agent; icon: typeof Sparkles; color: string }[] = [
{ id: 'Builder', icon: Sparkles, color: '#3b82f6' },
{ id: 'Compliance', icon: ShieldCheck, color: '#22c55e' },
{ id: 'Routing', icon: Route, color: '#f97316' },
{ id: 'ISO-20022', icon: FileText, color: '#a855f7' },
{ id: 'Settlement', icon: Landmark, color: '#eab308' },
{ id: 'Risk', icon: AlertTriangle, color: '#ef4444' },
{ id: 'Documentation', icon: BookOpen, color: '#6b7280' },
];
interface RightPanelProps {
width: number;
nodes: Node[];
edges: Edge[];
selectedNodes: Node[];
chatInputRef: React.RefObject<HTMLInputElement | null>;
onInsertBlock: (item: ComponentItem, position: { x: number; y: number }) => void;
onRunValidation: () => void;
onOptimizeRoute: () => void;
onRunCompliance: () => void;
onGenerateSettlement: () => void;
}
export default function RightPanel({
width, nodes, edges, selectedNodes, chatInputRef,
onInsertBlock, onRunValidation, onOptimizeRoute, onRunCompliance, onGenerateSettlement,
}: RightPanelProps) {
const [activeAgent, setActiveAgent] = useState<Agent>('Builder');
const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);
const [input, setInput] = useState('');
const [showAgentMenu, setShowAgentMenu] = useState(false);
const [showContext, setShowContext] = useState(false);
const [showThreads, setShowThreads] = useState(false);
const [scope, setScope] = useState<ConversationScope>('full-transaction');
const [showScopeMenu, setShowScopeMenu] = useState(false);
const messagesEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const getCanvasContext = () => {
const nodeLabels = nodes.map(n => (n.data as Record<string, unknown>).label as string);
const categories = [...new Set(nodes.map(n => (n.data as Record<string, unknown>).category as string))];
const hasCompliance = categories.includes('compliance');
const hasRouting = categories.includes('routing');
const disconnected = nodes.filter(n => !edges.some(e => e.source === n.id || e.target === n.id));
const selectedLabels = selectedNodes.map(n => (n.data as Record<string, unknown>).label as string);
return { nodeLabels, categories, hasCompliance, hasRouting, disconnected, selectedLabels, nodeCount: nodes.length, edgeCount: edges.length };
};
const sendMessage = () => {
if (!input.trim()) return;
const userMsg: ChatMessage = {
id: Date.now().toString(),
agent: 'User',
content: input,
timestamp: new Date(),
type: 'user',
};
setMessages(prev => [...prev, userMsg]);
const capturedInput = input;
setInput('');
setTimeout(() => {
const ctx = getCanvasContext();
const buildResponse = (): string => {
const lowerInput = capturedInput.toLowerCase();
switch (activeAgent) {
case 'Builder': {
if (selectedNodes.length > 0) {
const sel = (selectedNodes[0].data as Record<string, unknown>).label as string;
if (lowerInput.includes('explain')) return `The "${sel}" block ${getBlockExplanation(sel)}. It currently has ${edges.filter(e => e.source === selectedNodes[0].id || e.target === selectedNodes[0].id).length} connection(s).`;
if (lowerInput.includes('next') || lowerInput.includes('suggest')) return `After "${sel}", I recommend adding a ${suggestNextBlock(sel)}. This would complete the ${(selectedNodes[0].data as Record<string, unknown>).category} flow.`;
}
if (lowerInput.includes('build') || lowerInput.includes('create') || lowerInput.includes('set up') || lowerInput.includes('payment')) {
if (ctx.nodeCount === 0) return `To build a transaction flow, start by dragging a "Fiat Account" or "Stablecoin Wallet" from the left panel as your source. Then add a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" action and connect them.`;
return `Your graph has ${ctx.nodeCount} nodes. Try dragging a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" block onto the canvas and connecting it to your source.`;
}
if (ctx.disconnected.length > 0) return `I notice ${ctx.disconnected.length} disconnected node(s) in your graph: ${ctx.disconnected.map(n => (n.data as Record<string, unknown>).label).join(', ')}. Connect them to complete the flow.`;
return `I can help you build that flow. Try dragging a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" block onto the canvas and connecting it to your source. Your graph currently has ${ctx.nodeCount} nodes and ${ctx.edgeCount} connections.`;
}
case 'Compliance': {
if (lowerInput.includes('check') || lowerInput.includes('compliance') || lowerInput.includes('review')) {
if (!ctx.hasCompliance && ctx.nodeCount > 0) return `WARNING: Your transaction graph has ${ctx.nodeCount} nodes but no compliance checks. I recommend adding KYC and AML nodes before the settlement step. This is required for cross-border transactions.`;
if (ctx.hasCompliance) return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction. ${ctx.nodeCount} nodes verified against 47 compliance rules.`;
return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction.`;
}
if (lowerInput.includes('violation') || lowerInput.includes('failure')) return `Scanning graph for policy violations... ${ctx.hasCompliance ? 'All compliance nodes are properly configured. No violations found.' : 'No compliance nodes found in graph. Consider adding KYC/AML checks.'}`;
return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction.`;
}
case 'Routing': {
if (ctx.hasRouting) return `Analyzing ${ctx.nodeCount} nodes with routing configuration. Found optimal path via ${ctx.nodeLabels.find(l => l.includes('Route') || l.includes('Router')) || 'Banking Rail'}. Estimated fee: 0.02%, latency: 230ms.`;
return `Analyzing optimal routes... Found 3 execution paths. The best route via Banking Rail offers lowest fees at 0.02%. Your graph has ${ctx.nodeCount} nodes across ${ctx.edgeCount} connections.`;
}
case 'ISO-20022': {
if (ctx.nodeCount > 0) return `Based on your graph with ${ctx.nodeCount} nodes, I can generate a pain.001 message. The required fields from your current configuration: debtor (${ctx.nodeLabels[0] || 'source'}), creditor (${ctx.nodeLabels[ctx.nodeLabels.length - 1] || 'destination'}), amount, and currency.`;
return `I can generate a pain.001 message for this transfer. The required fields based on your current graph are: debtor, creditor, amount, and currency.`;
}
case 'Settlement': {
return `Current settlement window for this transaction type is T+1. ${ctx.nodeCount > 0 ? `Your graph has ${ctx.nodeCount} nodes ready for settlement processing.` : 'I recommend adding a settlement instruction block to specify your preferred CSD.'}`;
}
case 'Risk': {
if (ctx.nodeCount > 0) {
const riskLevel = ctx.hasCompliance ? 'LOW' : 'MEDIUM';
return `Risk assessment: ${riskLevel}. ${ctx.nodeCount} nodes evaluated. ${ctx.hasCompliance ? 'Compliance checks present.' : 'No compliance nodes — risk elevated.'} ${ctx.disconnected.length > 0 ? `${ctx.disconnected.length} disconnected node(s) detected.` : 'All nodes connected.'}`;
}
return `Risk assessment: LOW. Transaction amount is within normal parameters. No counterparty risk flags detected.`;
}
case 'Documentation': {
if (ctx.nodeCount > 0) return `Generating deal memo for "${ctx.nodeLabels[0]}" flow with ${ctx.nodeCount} nodes. Categories: ${ctx.categories.join(', ')}. ${ctx.edgeCount} connections mapped. ${ctx.hasCompliance ? 'Compliance: verified.' : 'Compliance: not yet added.'}`;
return `I'll generate a deal memo for this transaction. It will include the execution path, compliance checks, and settlement instructions.`;
}
default:
return 'How can I assist you?';
}
};
const reply: ChatMessage = {
id: (Date.now() + 1).toString(),
agent: activeAgent,
content: buildResponse(),
timestamp: new Date(),
type: 'agent',
};
setMessages(prev => [...prev, reply]);
}, 800);
};
const currentAgentDef = agents.find(a => a.id === activeAgent)!;
const CurrentIcon = currentAgentDef.icon;
const scopeLabels: Record<ConversationScope, string> = {
'current-node': 'Current Node',
'current-flow': 'Current Flow',
'full-transaction': 'Full Transaction',
'terminal': 'Terminal',
'compliance': 'Compliance Only',
};
// Context from canvas
const ctx = getCanvasContext();
const handleInsertBlock = () => {
const suggestions = ['transfer', 'kyc', 'banking-rail'];
const item = componentItems.find(c => c.id === suggestions[Math.floor(Math.random() * suggestions.length)]);
if (item) onInsertBlock(item, { x: 250 + Math.random() * 200, y: 150 + Math.random() * 200 });
};
return (
<div className="right-panel" style={{ width }}>
<div className="panel-header">
<div className="chat-header-agent" onClick={() => setShowAgentMenu(!showAgentMenu)}>
<CurrentIcon size={14} color={currentAgentDef.color} />
<span>{activeAgent} Agent</span>
<ChevronDown size={12} />
{showAgentMenu && (
<div className="agent-dropdown">
{agents.map(a => {
const Icon = a.icon;
return (
<div
key={a.id}
className={`agent-option ${a.id === activeAgent ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setActiveAgent(a.id); setShowAgentMenu(false); }}
>
<Icon size={14} color={a.color} />
<span>{a.id}</span>
</div>
);
})}
</div>
)}
</div>
<div className="chat-header-actions">
<div className="scope-selector" onClick={() => setShowScopeMenu(!showScopeMenu)}>
<Target size={11} />
<span>{scopeLabels[scope]}</span>
<ChevronDown size={10} />
{showScopeMenu && (
<div className="scope-dropdown">
{(Object.keys(scopeLabels) as ConversationScope[]).map(s => (
<div
key={s}
className={`scope-option ${s === scope ? 'active' : ''}`}
onClick={e => { e.stopPropagation(); setScope(s); setShowScopeMenu(false); }}
>
{scopeLabels[s]}
</div>
))}
</div>
)}
</div>
<button
className={`icon-btn-xs ${showThreads ? 'active' : ''}`}
onClick={() => setShowThreads(!showThreads)}
title="Thread History"
>
<History size={12} />
</button>
<button
className={`context-toggle ${showContext ? 'active' : ''}`}
onClick={() => setShowContext(!showContext)}
title="Toggle context panel"
>
Context
</button>
</div>
</div>
<div className="agent-tabs">
{agents.map(a => {
const Icon = a.icon;
return (
<button
key={a.id}
className={`agent-tab ${a.id === activeAgent ? 'active' : ''}`}
onClick={() => setActiveAgent(a.id)}
title={a.id}
style={a.id === activeAgent ? { borderBottomColor: a.color } : {}}
>
<Icon size={13} color={a.id === activeAgent ? a.color : '#666'} />
</button>
);
})}
</div>
{showThreads && (
<div className="thread-history">
<div className="thread-history-header">Thread History</div>
{sampleThreads.map(t => (
<div key={t.id} className="thread-item" onClick={() => setShowThreads(false)}>
<MessageSquare size={12} color={agents.find(a => a.id === t.agent)?.color} />
<div className="thread-item-content">
<span className="thread-item-title">{t.title}</span>
<span className="thread-item-meta">{t.agent} · {t.messageCount} messages</span>
</div>
</div>
))}
</div>
)}
{showContext && (
<div className="context-panel">
<div className="context-section">
<span className="context-label">Selected</span>
<span className="context-value">{ctx.selectedLabels.length > 0 ? ctx.selectedLabels.join(', ') : 'None'}</span>
</div>
<div className="context-section">
<span className="context-label">Nodes</span>
<span className="context-value">{ctx.nodeCount}</span>
</div>
<div className="context-section">
<span className="context-label">Connections</span>
<span className="context-value">{ctx.edgeCount}</span>
</div>
<div className="context-section">
<span className="context-label">Jurisdiction</span>
<span className="context-value">Multi</span>
</div>
<div className="context-section">
<span className="context-label">Counterparties</span>
<span className="context-value">{ctx.nodeCount > 0 ? Math.max(1, Math.floor(ctx.nodeCount / 3)) : 0}</span>
</div>
<div className="context-section">
<span className="context-label">Compliance</span>
<span className={`context-value ${ctx.hasCompliance ? 'pass' : (ctx.nodeCount > 0 ? 'warn' : '')}`}>
{ctx.hasCompliance ? 'Pass' : (ctx.nodeCount > 0 ? 'Missing' : 'N/A')}
</span>
</div>
<div className="context-section">
<span className="context-label">Categories</span>
<span className="context-value">{ctx.categories.length > 0 ? ctx.categories.join(', ') : '—'}</span>
</div>
<div className="context-section">
<span className="context-label">Est. Fees</span>
<span className="context-value">{ctx.nodeCount > 0 ? '0.02%' : '—'}</span>
</div>
</div>
)}
<div className="chat-messages">
{messages.map(msg => (
<div key={msg.id} className={`chat-message ${msg.type}`}>
<div className="message-header">
<span className="message-agent">{msg.agent}</span>
<span className="message-time">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEnd} />
</div>
<div className="action-tray">
<button className="action-tray-btn" title="Insert recommended block" onClick={handleInsertBlock}>
<Plus size={12} /> Insert Block
</button>
<button className="action-tray-btn" title="Repair graph" onClick={onRunValidation}>
<Wrench size={12} /> Repair
</button>
<button className="action-tray-btn" title="Optimize route" onClick={onOptimizeRoute}>
<Zap size={12} /> Optimize
</button>
<button className="action-tray-btn" title="Run compliance" onClick={onRunCompliance}>
<ShieldCheck size={12} /> Comply
</button>
<button className="action-tray-btn" title="Generate settlement message" onClick={onGenerateSettlement}>
<FileOutput size={12} /> Settle
</button>
<button className="action-tray-btn" title="Refresh context" onClick={() => setShowContext(true)}>
<RefreshCw size={12} />
</button>
</div>
<div className="chat-input-area">
<input
ref={chatInputRef}
type="text"
placeholder={`Ask ${activeAgent} Agent...`}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button className="send-btn" onClick={sendMessage} disabled={!input.trim()}>
<Send size={14} />
</button>
</div>
</div>
);
}
function getBlockExplanation(label: string): string {
const explanations: Record<string, string> = {
'Fiat Account': 'represents a traditional fiat currency account, typically used as a source or destination for fund transfers',
'Transfer': 'moves value from one account to another along a defined path',
'KYC': 'performs Know Your Customer verification before allowing the transaction to proceed',
'AML': 'runs Anti-Money Laundering screening against watchlists',
'Swap': 'exchanges one asset type for another at the current market rate',
'Banking Rail': 'routes the transaction through traditional banking infrastructure',
};
return explanations[label] || 'is a transaction primitive used in flow composition';
}
function suggestNextBlock(label: string): string {
const suggestions: Record<string, string> = {
'Fiat Account': 'Transfer or Convert block',
'Transfer': 'KYC compliance check',
'KYC': 'AML screening node',
'AML': 'Banking Rail or DEX Route',
'Swap': 'Settlement instruction',
'Banking Rail': 'Settlement instruction',
};
return suggestions[label] || 'compliance or routing node';
}

166
src/components/TitleBar.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import {
Search, Bell, ChevronDown, Play, FlaskConical, ShieldCheck, Zap,
Command, User, LogOut, Settings, Shield
} from 'lucide-react';
import type { SessionMode } from '../types';
import { sampleNotifications } from '../data/sampleData';
const modeColors: Record<SessionMode, string> = {
Sandbox: '#eab308',
Simulate: '#3b82f6',
Live: '#22c55e',
'Compliance Review': '#a855f7',
};
interface TitleBarProps {
mode: SessionMode;
onModeChange: (mode: SessionMode) => void;
onToggleCommandPalette: () => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
}
export default function TitleBar({ mode, onModeChange, onToggleCommandPalette, onValidate, onSimulate, onExecute }: TitleBarProps) {
const [showModeMenu, setShowModeMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [notifications, setNotifications] = useState(sampleNotifications);
const modes: SessionMode[] = ['Sandbox', 'Simulate', 'Live', 'Compliance Review'];
const unreadCount = notifications.filter(n => !n.read).length;
const markAllRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
};
const notifTypeColors: Record<string, string> = {
info: '#3b82f6',
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
};
return (
<div className="title-bar">
<div className="title-bar-left">
<div className="title-bar-logo">
<Zap size={18} color="#3b82f6" />
<span className="title-bar-name">TransactFlow</span>
</div>
<div className="title-bar-separator" />
<span className="title-bar-workspace">Institutional Workspace</span>
<div className="title-bar-separator" />
<div className="mode-selector" onClick={() => setShowModeMenu(!showModeMenu)}>
<div className="mode-dot" style={{ background: modeColors[mode] }} />
<span>{mode}</span>
<ChevronDown size={12} />
{showModeMenu && (
<div className="mode-dropdown">
{modes.map(m => (
<div
key={m}
className={`mode-option ${m === mode ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); onModeChange(m); setShowModeMenu(false); }}
>
<div className="mode-dot" style={{ background: modeColors[m] }} />
{m}
</div>
))}
</div>
)}
</div>
</div>
<div className="title-bar-center">
<button className="title-bar-search" onClick={onToggleCommandPalette}>
<Search size={13} />
<span>Search or run command...</span>
<kbd>Ctrl+K</kbd>
</button>
</div>
<div className="title-bar-right">
<button className="title-bar-action validate" title="Validate (Ctrl+Shift+V)" onClick={onValidate}>
<ShieldCheck size={15} />
<span>Validate</span>
</button>
<button className="title-bar-action simulate" title="Simulate (Ctrl+Shift+S)" onClick={onSimulate}>
<FlaskConical size={15} />
<span>Simulate</span>
</button>
<button className="title-bar-action execute" title="Execute (Ctrl+Shift+E)" onClick={onExecute}>
<Play size={15} />
<span>Execute</span>
</button>
<div className="title-bar-separator" />
{/* Notification bell with dropdown */}
<div className="notification-wrapper">
<button className="icon-btn" title="Notifications" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
<Bell size={16} />
{unreadCount > 0 && <span className="notification-badge">{unreadCount}</span>}
</button>
{showNotifications && (
<div className="notification-dropdown">
<div className="notification-dropdown-header">
<span>Notifications</span>
{unreadCount > 0 && (
<button className="mark-read-btn" onClick={markAllRead}>Mark all read</button>
)}
</div>
{notifications.map(n => (
<div key={n.id} className={`notification-item ${n.read ? 'read' : ''}`} onClick={() => setNotifications(prev => prev.map(x => x.id === n.id ? { ...x, read: true } : x))}>
<div className="notification-dot" style={{ background: notifTypeColors[n.type] }} />
<div className="notification-content">
<span className="notification-title">{n.title}</span>
<span className="notification-message">{n.message}</span>
<span className="notification-time">{n.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
))}
</div>
)}
</div>
<button className="icon-btn" title="Command Palette" onClick={onToggleCommandPalette}>
<Command size={16} />
</button>
{/* User menu with dropdown */}
<div className="user-menu-wrapper">
<button className="icon-btn user-btn" title="User Settings" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
<User size={16} />
</button>
{showUserMenu && (
<div className="user-menu-dropdown">
<div className="user-menu-profile">
<div className="user-avatar">JD</div>
<div>
<div className="user-name">Jane Doe</div>
<div className="user-role">Admin · Compliance Officer</div>
</div>
</div>
<div className="user-menu-divider" />
<div className="user-menu-item">
<User size={13} /> <span>Profile</span>
</div>
<div className="user-menu-item">
<Settings size={13} /> <span>Settings</span>
</div>
<div className="user-menu-item">
<Shield size={13} /> <span>Permissions</span>
<span className="user-menu-badge">Admin</span>
</div>
<div className="user-menu-divider" />
<div className="user-menu-item logout">
<LogOut size={13} /> <span>Sign Out</span>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { AlertTriangle, CheckCircle2, XCircle, Shield } from 'lucide-react';
type TransactionNodeData = {
label: string;
category: string;
icon: string;
color: string;
status?: 'valid' | 'warning' | 'error';
};
const complianceCategories = ['compliance'];
const routingCategories = ['routing'];
function TransactionNodeComponent({ data, selected }: NodeProps) {
const nodeData = data as unknown as TransactionNodeData;
const statusIcon = nodeData.status === 'valid' ? <CheckCircle2 size={10} color="#22c55e" /> :
nodeData.status === 'warning' ? <AlertTriangle size={10} color="#eab308" /> :
nodeData.status === 'error' ? <XCircle size={10} color="#ef4444" /> : null;
const isCompliance = complianceCategories.includes(nodeData.category);
const isRouting = routingCategories.includes(nodeData.category);
return (
<div className={`transaction-node ${selected ? 'selected' : ''} ${nodeData.status ? `status-${nodeData.status}` : ''}`}
style={{ borderColor: selected ? '#3b82f6' : nodeData.color + '60' }}>
<Handle type="target" position={Position.Left} className="node-handle" />
<div className="node-header" style={{ borderBottomColor: nodeData.color + '30' }}>
<span className="node-icon">{nodeData.icon}</span>
<span className="node-label">{nodeData.label}</span>
<div className="node-badges">
{isCompliance && (
<span className="node-badge compliance" title="Compliance node">
<Shield size={8} />
</span>
)}
{isRouting && (
<span className="node-badge routing" title="Routing node">
🔀
</span>
)}
{statusIcon && <span className="node-status">{statusIcon}</span>}
</div>
</div>
<div className="node-body">
<span className="node-category" style={{ color: nodeData.color }}>{nodeData.category}</span>
</div>
<Handle type="source" position={Position.Right} className="node-handle" />
</div>
);
}
export default memo(TransactionNodeComponent);

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import {
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
ExternalLink, ChevronDown
} 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: 'accounts', label: 'Accounts', icon: Building2, path: '/accounts' },
{ id: 'treasury', label: 'Treasury', icon: Landmark, path: '/treasury' },
{ id: 'reporting', label: 'Reporting', icon: FileText, path: '/reporting' },
{ id: 'compliance', label: 'Compliance & Risk', icon: Shield, path: '/compliance' },
{ id: 'settlements', label: 'Settlements', icon: CheckSquare, path: '/settlements' },
];
interface PortalLayoutProps {
children: React.ReactNode;
}
export default function PortalLayout({ children }: PortalLayoutProps) {
const { user, wallet, disconnect } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const currentPath = location.pathname;
const copyAddress = () => {
if (wallet?.address) {
navigator.clipboard.writeText(wallet.address);
}
};
return (
<div className="portal-layout">
<div className="portal-topbar">
<div className="portal-topbar-left">
<div className="portal-logo" onClick={() => navigate('/dashboard')}>
<Building2 size={22} color="#3b82f6" />
{!collapsed && (
<div className="portal-logo-text">
<span className="portal-logo-name">Solace Bank Group</span>
<span className="portal-logo-plc">PLC</span>
</div>
)}
</div>
</div>
<div className="portal-topbar-center">
<div className="portal-env-badge">
<span className="env-dot" />
Production
</div>
</div>
<div className="portal-topbar-right">
<div className="portal-notif-wrapper">
<button className="portal-icon-btn" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
<Bell size={18} />
<span className="portal-notif-badge">3</span>
</button>
{showNotifications && (
<div className="portal-dropdown notifications-dropdown">
<div className="portal-dropdown-header">Notifications</div>
<div className="portal-dropdown-item warning">
<span className="dropdown-dot warning" />
<div>
<div className="dropdown-title">AML Alert</div>
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
</div>
</div>
<div className="portal-dropdown-item info">
<span className="dropdown-dot info" />
<div>
<div className="dropdown-title">Settlement Confirmed</div>
<div className="dropdown-desc">TX-2024-0847 settled</div>
</div>
</div>
<div className="portal-dropdown-item">
<span className="dropdown-dot success" />
<div>
<div className="dropdown-title">Report Ready</div>
<div className="dropdown-desc">Q4 IFRS Balance Sheet</div>
</div>
</div>
</div>
)}
</div>
<div className="portal-user-wrapper">
<button className="portal-user-btn" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
<div className="portal-avatar">
<User size={14} />
</div>
<div className="portal-user-info">
<span className="portal-user-name">{user?.displayName || 'User'}</span>
<span className="portal-user-role">{user?.role?.replace('_', ' ') || 'Admin'}</span>
</div>
<ChevronDown size={12} />
</button>
{showUserMenu && (
<div className="portal-dropdown user-dropdown">
<div className="portal-dropdown-header">Account</div>
<div className="portal-dropdown-section">
<div className="portal-wallet-addr">
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
<button className="copy-btn" onClick={copyAddress} title="Copy address"><Copy size={12} /></button>
</div>
<div className="portal-wallet-bal">
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
<span className="chain-badge">Chain {wallet?.chainId || 1}</span>
</div>
</div>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action" onClick={() => navigate('/settings')}>
<Settings size={14} /> Settings
</button>
<button className="portal-dropdown-action" onClick={() => window.open('https://etherscan.io', '_blank')}>
<ExternalLink size={14} /> View on Explorer
</button>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action danger" onClick={disconnect}>
<LogOut size={14} /> Disconnect Wallet
</button>
</div>
)}
</div>
</div>
</div>
<div className="portal-body">
<nav className={`portal-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="portal-nav-items">
{navItems.map(item => {
const Icon = item.icon;
const isActive = currentPath === item.path || (item.path !== '/dashboard' && currentPath.startsWith(item.path));
return (
<button
key={item.id}
className={`portal-nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}
title={collapsed ? item.label : undefined}
>
<Icon size={18} />
{!collapsed && <span>{item.label}</span>}
{isActive && <div className="nav-active-indicator" />}
</button>
);
})}
</div>
<div className="portal-nav-footer">
<button className="portal-nav-item" onClick={() => navigate('/settings')} title={collapsed ? 'Settings' : undefined}>
<Settings size={18} />
{!collapsed && <span>Settings</span>}
</button>
<button className="portal-collapse-btn" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</nav>
<main className="portal-content">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { BrowserProvider, formatEther } from 'ethers';
import type { AuthState, WalletInfo, PortalUser, UserRole, Permission } from '../types/portal';
interface AuthContextType extends AuthState {
connectWallet: (provider: 'metamask' | 'walletconnect' | 'coinbase') => Promise<void>;
disconnect: () => void;
error: string | null;
}
const AuthContext = createContext<AuthContextType | null>(null);
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
admin: [
'accounts.view', 'accounts.manage', 'accounts.create',
'transactions.view', 'transactions.create', 'transactions.approve', 'transactions.execute',
'treasury.view', 'treasury.manage', 'treasury.rebalance',
'compliance.view', 'compliance.manage', 'compliance.override',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view', 'settlements.approve',
'admin.users', 'admin.settings', 'admin.audit',
],
treasurer: [
'accounts.view', 'accounts.manage',
'transactions.view', 'transactions.create', 'transactions.approve',
'treasury.view', 'treasury.manage', 'treasury.rebalance',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view', 'settlements.approve',
],
analyst: [
'accounts.view', 'transactions.view', 'treasury.view',
'reports.view', 'reports.generate', 'settlements.view',
],
compliance_officer: [
'accounts.view', 'transactions.view', 'treasury.view',
'compliance.view', 'compliance.manage',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view',
],
auditor: [
'accounts.view', 'transactions.view', 'treasury.view',
'compliance.view', 'reports.view', 'reports.export',
'settlements.view', 'admin.audit',
],
viewer: ['accounts.view', 'transactions.view', 'treasury.view', 'reports.view', 'settlements.view'],
};
const AUTH_STORAGE_KEY = 'solace-auth';
function generateUser(address: string): PortalUser {
return {
id: `usr-${address.slice(2, 10)}`,
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
role: 'admin',
permissions: ROLE_PERMISSIONS['admin'],
institution: 'Solace Bank Group PLC',
department: 'Treasury Operations',
lastLogin: new Date(),
walletAddress: address,
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
wallet: null,
user: null,
loading: true,
});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const saved = localStorage.getItem(AUTH_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setState({
isAuthenticated: true,
wallet: parsed.wallet,
user: { ...parsed.user, lastLogin: new Date(parsed.user.lastLogin) },
loading: false,
});
return;
} catch { /* ignore */ }
}
setState(prev => ({ ...prev, loading: false }));
}, []);
const connectWallet = useCallback(async (providerType: 'metamask' | 'walletconnect' | 'coinbase') => {
setError(null);
setState(prev => ({ ...prev, loading: true }));
try {
let address: string;
let chainId: number;
let balance: string;
const ethereum = (window as unknown as Record<string, unknown>).ethereum as {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
isMetaMask?: boolean;
isCoinbaseWallet?: boolean;
chainId?: string;
} | undefined;
if (ethereum && (providerType === 'metamask' || providerType === 'coinbase')) {
const accounts = await ethereum.request({ method: 'eth_requestAccounts' }) as string[];
if (!accounts || accounts.length === 0) throw new Error('No accounts returned');
const provider = new BrowserProvider(ethereum as never);
const signer = await provider.getSigner();
address = await signer.getAddress();
const network = await provider.getNetwork();
chainId = Number(network.chainId);
const bal = await provider.getBalance(address);
balance = formatEther(bal);
} else {
// Demo mode — simulate wallet connection for environments without MetaMask
await new Promise(resolve => setTimeout(resolve, 1200));
address = '0x' + Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
chainId = 1;
balance = (Math.random() * 100).toFixed(4);
}
const wallet: WalletInfo = { address, chainId, balance, provider: providerType };
const user = generateUser(address);
const newState: AuthState = { isAuthenticated: true, wallet, user, loading: false };
setState(newState);
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ wallet, user }));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to connect wallet';
setError(msg);
setState(prev => ({ ...prev, loading: false }));
}
}, []);
const disconnect = useCallback(() => {
setState({ isAuthenticated: false, wallet: null, user: null, loading: false });
localStorage.removeItem(AUTH_STORAGE_KEY);
}, []);
return (
<AuthContext.Provider value={{ ...state, connectWallet, disconnect, error }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

81
src/data/components.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { ComponentItem } from '../types';
export const componentCategories = [
{ id: 'assets', label: 'Asset Primitives', icon: '💰' },
{ id: 'actions', label: 'Transaction Actions', icon: '⚡' },
{ id: 'routing', label: 'Routing Components', icon: '🔀' },
{ id: 'compliance', label: 'Compliance Components', icon: '🛡️' },
{ id: 'messaging', label: 'ISO-20022 / Messaging', icon: '📨' },
{ id: 'logic', label: 'Logic / Control', icon: '🔧' },
{ id: 'templates', label: 'Templates', icon: '📋' },
];
export const componentItems: ComponentItem[] = [
// Asset Primitives
{ id: 'fiat-account', label: 'Fiat Account', category: 'assets', icon: '🏦', description: 'Traditional fiat currency account', color: '#22c55e' },
{ id: 'bank-ledger', label: 'Bank Ledger', category: 'assets', icon: '📒', description: 'Core banking ledger entry', color: '#22c55e' },
{ id: 'stablecoin-wallet', label: 'Stablecoin Wallet', category: 'assets', icon: '🪙', description: 'Stablecoin holding wallet', color: '#3b82f6' },
{ id: 'tokenized-security', label: 'Tokenized Security', category: 'assets', icon: '📊', description: 'Tokenized securities instrument', color: '#a855f7' },
{ id: 'commodity-instrument', label: 'Commodity Instrument', category: 'assets', icon: '🛢️', description: 'Physical or digital commodity', color: '#f97316' },
{ id: 'cash-position', label: 'Cash Position', category: 'assets', icon: '💵', description: 'Cash balance position', color: '#22c55e' },
{ id: 'custody-account', label: 'Custody Account', category: 'assets', icon: '🔐', description: 'Custodial holding account', color: '#3b82f6' },
{ id: 'treasury-source', label: 'Treasury Source', category: 'assets', icon: '🏛️', description: 'Treasury management source', color: '#22c55e' },
// Transaction Actions
{ id: 'transfer', label: 'Transfer', category: 'actions', icon: '➡️', description: 'Transfer value between accounts', color: '#3b82f6' },
{ id: 'swap', label: 'Swap', category: 'actions', icon: '🔄', description: 'Swap between asset types', color: '#3b82f6' },
{ id: 'convert', label: 'Convert', category: 'actions', icon: '💱', description: 'FX or asset conversion', color: '#3b82f6' },
{ id: 'split', label: 'Split', category: 'actions', icon: '✂️', description: 'Split value into multiple paths', color: '#3b82f6' },
{ id: 'merge', label: 'Merge', category: 'actions', icon: '🔗', description: 'Merge multiple inputs', color: '#3b82f6' },
{ id: 'lock-unlock', label: 'Lock / Unlock', category: 'actions', icon: '🔒', description: 'Lock or unlock assets', color: '#eab308' },
{ id: 'escrow', label: 'Escrow', category: 'actions', icon: '⏳', description: 'Escrow hold mechanism', color: '#eab308' },
{ id: 'mint-burn', label: 'Mint / Burn', category: 'actions', icon: '🔥', description: 'Mint or burn tokens', color: '#ef4444' },
{ id: 'allocate', label: 'Allocate', category: 'actions', icon: '📤', description: 'Allocate to destinations', color: '#3b82f6' },
{ id: 'rebalance', label: 'Rebalance', category: 'actions', icon: '⚖️', description: 'Portfolio rebalancing', color: '#3b82f6' },
// Routing Components
{ id: 'dex-route', label: 'DEX Route', category: 'routing', icon: '🌐', description: 'Decentralized exchange routing', color: '#a855f7' },
{ id: 'cex-route', label: 'CEX Route', category: 'routing', icon: '🏢', description: 'Centralized exchange routing', color: '#3b82f6' },
{ id: 'otc-desk', label: 'OTC Desk Route', category: 'routing', icon: '🤝', description: 'Over-the-counter desk', color: '#3b82f6' },
{ id: 'banking-rail', label: 'Banking Rail', category: 'routing', icon: '🏦', description: 'Traditional banking rail', color: '#22c55e' },
{ id: 'securities-clearing', label: 'Securities Clearing', category: 'routing', icon: '📋', description: 'Securities clearing route', color: '#a855f7' },
{ id: 'commodity-venue', label: 'Commodity Venue', category: 'routing', icon: '🏭', description: 'Commodity trading venue', color: '#f97316' },
{ id: 'best-execution', label: 'Best Execution Router', category: 'routing', icon: '🎯', description: 'Optimal execution path finder', color: '#22c55e' },
{ id: 'failover-router', label: 'Failover Router', category: 'routing', icon: '🔁', description: 'Fallback routing handler', color: '#eab308' },
// Compliance Components
{ id: 'kyc', label: 'KYC', category: 'compliance', icon: '👤', description: 'Know Your Customer check', color: '#22c55e' },
{ id: 'aml', label: 'AML', category: 'compliance', icon: '🔍', description: 'Anti-Money Laundering screen', color: '#22c55e' },
{ id: 'sanctions', label: 'Sanctions Screening', category: 'compliance', icon: '🚫', description: 'Sanctions list screening', color: '#ef4444' },
{ id: 'jurisdiction', label: 'Jurisdiction Filter', category: 'compliance', icon: '🌍', description: 'Jurisdiction-based filtering', color: '#eab308' },
{ id: 'suitability', label: 'Suitability Check', category: 'compliance', icon: '✅', description: 'Investment suitability assessment', color: '#22c55e' },
{ id: 'travel-rule', label: 'Travel Rule Handler', category: 'compliance', icon: '✈️', description: 'FATF Travel Rule compliance', color: '#3b82f6' },
{ id: 'threshold-alert', label: 'Threshold Alert', category: 'compliance', icon: '⚠️', description: 'Amount threshold monitoring', color: '#eab308' },
{ id: 'approval-gate', label: 'Approval Gate', category: 'compliance', icon: '🚪', description: 'Manual approval checkpoint', color: '#eab308' },
// ISO-20022 / Messaging
{ id: 'pain001', label: 'pain.001', category: 'messaging', icon: '📄', description: 'Customer Credit Transfer Initiation', color: '#a855f7' },
{ id: 'pacs008', label: 'pacs.008', category: 'messaging', icon: '📄', description: 'FI to FI Customer Credit Transfer', color: '#a855f7' },
{ id: 'camt-messages', label: 'camt Messages', category: 'messaging', icon: '📄', description: 'Cash Management messages', color: '#a855f7' },
{ id: 'mapping-transformer', label: 'Mapping Transformer', category: 'messaging', icon: '🔀', description: 'Message format transformer', color: '#a855f7' },
{ id: 'message-validator', label: 'Message Validator', category: 'messaging', icon: '✔️', description: 'ISO-20022 message validation', color: '#a855f7' },
{ id: 'ack-recon', label: 'Ack / Reconciliation', category: 'messaging', icon: '🔄', description: 'Acknowledgement & reconciliation', color: '#a855f7' },
// Logic / Control
{ id: 'if-else', label: 'If / Else', category: 'logic', icon: '🔀', description: 'Conditional branching', color: '#3b82f6' },
{ id: 'branch-jurisdiction', label: 'Branch by Jurisdiction', category: 'logic', icon: '🌍', description: 'Route by legal jurisdiction', color: '#eab308' },
{ id: 'branch-asset', label: 'Branch by Asset Class', category: 'logic', icon: '📊', description: 'Route by asset classification', color: '#3b82f6' },
{ id: 'time-lock', label: 'Time Lock', category: 'logic', icon: '⏰', description: 'Time-based lock condition', color: '#eab308' },
{ id: 'amount-threshold', label: 'Amount Threshold', category: 'logic', icon: '📏', description: 'Amount-based branching', color: '#eab308' },
{ id: 'risk-score', label: 'Risk Score Gate', category: 'logic', icon: '📈', description: 'Risk score evaluation gate', color: '#ef4444' },
{ id: 'manual-approval', label: 'Manual Approval', category: 'logic', icon: '✋', description: 'Human approval step', color: '#eab308' },
{ id: 'retry-policy', label: 'Retry Policy', category: 'logic', icon: '🔁', description: 'Failure retry configuration', color: '#3b82f6' },
// Templates
{ id: 'tpl-cross-border', label: 'Cross-Border Payment', category: 'templates', icon: '🌐', description: 'Multi-jurisdiction payment template', color: '#22c55e' },
{ id: 'tpl-commodity-settlement', label: 'Commodity Settlement', category: 'templates', icon: '🛢️', description: 'Commodity-backed settlement flow', color: '#f97316' },
{ id: 'tpl-stablecoin-offramp', label: 'Stablecoin Off-Ramp', category: 'templates', icon: '🪙', description: 'Stablecoin to fiat off-ramp', color: '#3b82f6' },
{ id: 'tpl-securities-collateral', label: 'Securities Collateral', category: 'templates', icon: '📊', description: 'Securities collateral transfer', color: '#a855f7' },
{ id: 'tpl-treasury-rebalance', label: 'Treasury Rebalancing', category: 'templates', icon: '⚖️', description: 'Treasury position rebalancing', color: '#22c55e' },
{ id: 'tpl-custody-movement', label: 'Custody Movement', category: 'templates', icon: '🔐', description: 'Institutional custody transfer', color: '#3b82f6' },
];

138
src/data/portalData.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { Account, FinancialSummary, TreasuryPosition, CashForecast, ReportConfig, ComplianceAlert, SettlementRecord, PortalModule } from '../types/portal';
export const portalModules: PortalModule[] = [
{ id: 'dashboard', name: 'Overview', icon: '📊', description: 'Consolidated financial dashboard with real-time portfolio metrics', path: '/dashboard', requiredPermission: 'accounts.view', status: 'active' },
{ id: 'transaction-builder', name: 'Transaction Builder', icon: '⚡', description: 'IDE-style drag-and-drop transaction composition workspace', path: '/transaction-builder', requiredPermission: 'transactions.create', status: 'active' },
{ id: 'accounts', name: 'Accounts', icon: '🏦', description: 'Multi-account and subaccount management with consolidated views', path: '/accounts', requiredPermission: 'accounts.view', status: 'active' },
{ id: 'treasury', name: 'Treasury', icon: '💎', description: 'Treasury operations, cash management, and position monitoring', path: '/treasury', requiredPermission: 'treasury.view', status: 'active' },
{ id: 'reporting', name: 'Reporting', icon: '📋', description: 'IPSAS, US GAAP, and IFRS compliant financial reporting', path: '/reporting', requiredPermission: 'reports.view', status: 'active' },
{ id: 'compliance', name: 'Compliance & Risk', icon: '🛡️', description: 'Regulatory compliance monitoring and risk management', path: '/compliance', requiredPermission: 'compliance.view', status: 'active' },
{ id: 'settlements', name: 'Settlements', icon: '✅', description: 'Settlement lifecycle tracking and clearing operations', path: '/settlements', requiredPermission: 'settlements.view', status: 'active' },
];
export const sampleAccounts: Account[] = [
{
id: 'acc-001', name: 'Main Operating Account', type: 'operating', currency: 'USD',
balance: 45_250_000.00, availableBalance: 44_800_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB82 SLCE 0099 7100 0012 34',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 300000),
subaccounts: [
{ id: 'acc-001a', name: 'Payroll Sub-Account', type: 'operating', currency: 'USD', balance: 2_100_000, availableBalance: 2_100_000, status: 'active', parentId: 'acc-001', institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 600000) },
{ id: 'acc-001b', name: 'Vendor Payments', type: 'operating', currency: 'USD', balance: 3_500_000, availableBalance: 3_200_000, status: 'active', parentId: 'acc-001', institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 900000) },
],
},
{
id: 'acc-002', name: 'EUR Treasury Account', type: 'treasury', currency: 'EUR',
balance: 18_750_000.00, availableBalance: 18_500_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB45 SLCE 0099 7200 0056 78',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 1200000),
},
{
id: 'acc-003', name: 'Digital Asset Custody', type: 'custody', currency: 'BTC',
balance: 125.5, availableBalance: 120.0, status: 'active',
institution: 'Solace Bank Group PLC', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',
lastActivity: new Date(Date.now() - 1800000),
},
{
id: 'acc-004', name: 'Stablecoin Reserve', type: 'stablecoin', currency: 'USDC',
balance: 12_000_000.00, availableBalance: 11_950_000.00, status: 'active',
institution: 'Solace Bank Group PLC', walletAddress: '0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b',
lastActivity: new Date(Date.now() - 2400000),
},
{
id: 'acc-005', name: 'Nostro - Deutsche Bank', type: 'nostro', currency: 'EUR',
balance: 5_200_000.00, availableBalance: 5_200_000.00, status: 'active',
institution: 'Deutsche Bank AG', swift: 'DEUTDEFF',
lastActivity: new Date(Date.now() - 3600000),
},
{
id: 'acc-006', name: 'Collateral Account', type: 'collateral', currency: 'USD',
balance: 8_000_000.00, availableBalance: 0, status: 'active',
institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 7200000),
},
{
id: 'acc-007', name: 'GBP Settlement Account', type: 'settlement', currency: 'GBP',
balance: 3_400_000.00, availableBalance: 3_150_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB12 SLCE 0099 7300 0098 76',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 5400000),
},
{
id: 'acc-008', name: 'Escrow - Project Alpha', type: 'escrow', currency: 'USD',
balance: 15_000_000.00, availableBalance: 0, status: 'frozen',
institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 86400000),
},
];
export const financialSummary: FinancialSummary = {
totalAssets: 892_450_000,
totalLiabilities: 654_200_000,
netPosition: 238_250_000,
unrealizedPnL: 4_125_000,
realizedPnL: 12_680_000,
pendingSettlements: 28_500_000,
dailyVolume: 156_000_000,
currency: 'USD',
};
export const treasuryPositions: TreasuryPosition[] = [
{ id: 'pos-1', assetClass: 'Fixed Income', instrument: 'US Treasury 10Y', quantity: 50_000_000, marketValue: 49_250_000, costBasis: 48_500_000, unrealizedPnL: 750_000, currency: 'USD', custodian: 'State Street', maturityDate: new Date('2034-11-15') },
{ id: 'pos-2', assetClass: 'Fixed Income', instrument: 'UK Gilt 5Y', quantity: 20_000_000, marketValue: 19_800_000, costBasis: 20_100_000, unrealizedPnL: -300_000, currency: 'GBP', custodian: 'Euroclear' },
{ id: 'pos-3', assetClass: 'Digital Assets', instrument: 'Bitcoin (BTC)', quantity: 125.5, marketValue: 8_425_000, costBasis: 6_275_000, unrealizedPnL: 2_150_000, currency: 'USD', custodian: 'BitGo' },
{ id: 'pos-4', assetClass: 'Digital Assets', instrument: 'USDC Stablecoin', quantity: 12_000_000, marketValue: 12_000_000, costBasis: 12_000_000, unrealizedPnL: 0, currency: 'USD', custodian: 'Circle' },
{ id: 'pos-5', assetClass: 'FX', instrument: 'EUR/USD Spot', quantity: 18_750_000, marketValue: 20_250_000, costBasis: 19_875_000, unrealizedPnL: 375_000, currency: 'USD', custodian: 'Solace Bank' },
{ id: 'pos-6', assetClass: 'Commodities', instrument: 'Gold (XAU)', quantity: 5_000, marketValue: 11_500_000, costBasis: 9_750_000, unrealizedPnL: 1_750_000, currency: 'USD', custodian: 'HSBC Vault' },
{ id: 'pos-7', assetClass: 'Equities', instrument: 'S&P 500 ETF', quantity: 100_000, marketValue: 45_200_000, costBasis: 42_000_000, unrealizedPnL: 3_200_000, currency: 'USD', custodian: 'State Street' },
{ id: 'pos-8', assetClass: 'Fixed Income', instrument: 'Corporate Bond AAA', quantity: 15_000_000, marketValue: 14_850_000, costBasis: 15_000_000, unrealizedPnL: -150_000, currency: 'USD', custodian: 'JP Morgan', maturityDate: new Date('2028-06-30') },
];
export const cashForecasts: CashForecast[] = Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() + i);
const base = 45_250_000 + Math.sin(i * 0.3) * 5_000_000;
return {
date,
projected: Math.round(base + (Math.random() - 0.5) * 2_000_000),
actual: i < 3 ? Math.round(base + (Math.random() - 0.5) * 1_000_000) : undefined,
currency: 'USD',
};
});
export const reportConfigs: ReportConfig[] = [
{ id: 'rpt-1', name: 'Balance Sheet - IFRS', standard: 'IFRS', type: 'balance_sheet', period: 'quarterly', status: 'published', generatedAt: new Date(Date.now() - 86400000 * 5), generatedBy: 'J. Thompson' },
{ id: 'rpt-2', name: 'Income Statement - US GAAP', standard: 'US_GAAP', type: 'income_statement', period: 'monthly', status: 'reviewed', generatedAt: new Date(Date.now() - 86400000 * 2), generatedBy: 'M. Chen' },
{ id: 'rpt-3', name: 'Cash Flow Statement - IPSAS', standard: 'IPSAS', type: 'cash_flow', period: 'quarterly', status: 'generated', generatedAt: new Date(Date.now() - 86400000), generatedBy: 'System' },
{ id: 'rpt-4', name: 'Trial Balance - IFRS', standard: 'IFRS', type: 'trial_balance', period: 'monthly', status: 'published', generatedAt: new Date(Date.now() - 86400000 * 3), generatedBy: 'A. Patel' },
{ id: 'rpt-5', name: 'Regulatory Report - US GAAP', standard: 'US_GAAP', type: 'regulatory', period: 'quarterly', status: 'draft', generatedBy: 'System' },
{ id: 'rpt-6', name: 'Position Summary - IFRS', standard: 'IFRS', type: 'position_summary', period: 'daily', status: 'published', generatedAt: new Date(Date.now() - 3600000), generatedBy: 'System' },
{ id: 'rpt-7', name: 'Risk Exposure - IPSAS', standard: 'IPSAS', type: 'risk_exposure', period: 'weekly', status: 'generated', generatedAt: new Date(Date.now() - 86400000 * 1), generatedBy: 'R. Kumar' },
{ id: 'rpt-8', name: 'Compliance Summary - US GAAP', standard: 'US_GAAP', type: 'compliance_summary', period: 'monthly', status: 'reviewed', generatedAt: new Date(Date.now() - 86400000 * 4), generatedBy: 'L. Wright' },
];
export const complianceAlerts: ComplianceAlert[] = [
{ id: 'ca-1', severity: 'critical', category: 'AML', message: 'Unusual transaction pattern detected on ACC-001: 15 transactions exceeding $500K in 24h', timestamp: new Date(Date.now() - 1800000), status: 'open' },
{ id: 'ca-2', severity: 'high', category: 'KYC', message: 'KYC documentation expiring for 3 institutional counterparties within 30 days', timestamp: new Date(Date.now() - 3600000), status: 'acknowledged', assignedTo: 'Compliance Team' },
{ id: 'ca-3', severity: 'medium', category: 'Sanctions', message: 'New OFAC SDN list update — 12 new entries require screening', timestamp: new Date(Date.now() - 7200000), status: 'open' },
{ id: 'ca-4', severity: 'high', category: 'Travel Rule', message: 'Travel rule compliance gap: 2 outbound transfers missing originator data', timestamp: new Date(Date.now() - 10800000), status: 'open' },
{ id: 'ca-5', severity: 'low', category: 'Reporting', message: 'Q4 IPSAS regulatory filing due in 14 days', timestamp: new Date(Date.now() - 14400000), status: 'acknowledged', assignedTo: 'Finance Team' },
{ id: 'ca-6', severity: 'medium', category: 'Risk', message: 'Counterparty credit rating downgrade: Acme Corp (BBB → BB+)', timestamp: new Date(Date.now() - 21600000), status: 'resolved' },
];
export const settlementRecords: SettlementRecord[] = [
{ id: 'stl-1', txId: 'TX-2024-0851', type: 'DVP', status: 'pending', amount: 5_000_000, currency: 'USD', counterparty: 'Goldman Sachs', settlementDate: new Date(Date.now() + 86400000), valueDate: new Date(Date.now() + 86400000), csd: 'DTCC' },
{ id: 'stl-2', txId: 'TX-2024-0852', type: 'PVP', status: 'matched', amount: 12_500_000, currency: 'EUR', counterparty: 'Deutsche Bank', settlementDate: new Date(Date.now() + 172800000), valueDate: new Date(Date.now() + 172800000), csd: 'Euroclear' },
{ id: 'stl-3', txId: 'TX-2024-0853', type: 'FOP', status: 'affirmed', amount: 2_000_000, currency: 'GBP', counterparty: 'Barclays', settlementDate: new Date(), valueDate: new Date(), csd: 'CREST' },
{ id: 'stl-4', txId: 'TX-2024-0854', type: 'internal', status: 'settled', amount: 8_000_000, currency: 'USD', counterparty: 'Internal Transfer', settlementDate: new Date(Date.now() - 86400000), valueDate: new Date(Date.now() - 86400000) },
{ id: 'stl-5', txId: 'TX-2024-0855', type: 'DVP', status: 'failed', amount: 3_250_000, currency: 'USD', counterparty: 'Morgan Stanley', settlementDate: new Date(Date.now() - 172800000), valueDate: new Date(Date.now() - 172800000), csd: 'DTCC' },
{ id: 'stl-6', txId: 'TX-2024-0856', type: 'PVP', status: 'pending', amount: 15_000_000, currency: 'JPY', counterparty: 'Nomura', settlementDate: new Date(Date.now() + 259200000), valueDate: new Date(Date.now() + 259200000) },
];
export const recentActivity = [
{ id: 'ra-1', action: 'Transfer Executed', detail: '$2.5M USD → EUR Treasury Account', timestamp: new Date(Date.now() - 300000), status: 'success' as const },
{ id: 'ra-2', action: 'Settlement Confirmed', detail: 'TX-2024-0847 settled via SWIFT', timestamp: new Date(Date.now() - 1200000), status: 'success' as const },
{ id: 'ra-3', action: 'Compliance Alert', detail: 'AML threshold exceeded on ACC-001', timestamp: new Date(Date.now() - 1800000), status: 'warning' as const },
{ id: 'ra-4', action: 'Report Generated', detail: 'Q4 Balance Sheet (IFRS)', timestamp: new Date(Date.now() - 3600000), status: 'info' as const },
{ id: 'ra-5', action: 'Position Rebalanced', detail: 'Treasury portfolio rebalanced per policy', timestamp: new Date(Date.now() - 5400000), status: 'success' as const },
{ id: 'ra-6', action: 'Settlement Failed', detail: 'TX-2024-0855 DVP failed — counterparty mismatch', timestamp: new Date(Date.now() - 7200000), status: 'error' as const },
{ id: 'ra-7', action: 'New Account Created', detail: 'GBP Settlement Account activated', timestamp: new Date(Date.now() - 10800000), status: 'info' as const },
{ id: 'ra-8', action: 'KYC Review', detail: 'Counterparty due diligence completed for Barclays', timestamp: new Date(Date.now() - 14400000), status: 'success' as const },
];

88
src/data/sampleData.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { ChatMessage, TerminalEntry, ValidationIssue, AuditEntry, SettlementItem, Notification, ThreadEntry } from '../types';
export const sampleMessages: ChatMessage[] = [
{
id: '1',
agent: 'Builder',
content: 'Transaction graph initialized. Drop components from the left panel to begin building your flow.',
timestamp: new Date(Date.now() - 300000),
type: 'agent',
},
{
id: '2',
agent: 'Compliance',
content: 'Compliance engine ready. I\'ll monitor your graph for policy violations as you build.',
timestamp: new Date(Date.now() - 240000),
type: 'agent',
},
{
id: '3',
agent: 'System',
content: 'Environment: Sandbox | Region: Multi-jurisdiction | Protocol: ISO-20022 enabled',
timestamp: new Date(Date.now() - 180000),
type: 'system',
},
];
export const sampleTerminal: TerminalEntry[] = [
{ id: '1', timestamp: new Date(Date.now() - 60000), level: 'info', source: 'system', message: 'Transaction builder initialized' },
{ id: '2', timestamp: new Date(Date.now() - 55000), level: 'info', source: 'compliance', message: 'Compliance engine v3.2.1 loaded' },
{ id: '3', timestamp: new Date(Date.now() - 50000), level: 'success', source: 'routing', message: 'Route optimizer connected to 12 venues' },
{ id: '4', timestamp: new Date(Date.now() - 45000), level: 'info', source: 'iso20022', message: 'Message schemas loaded: pain.001, pacs.008, camt.053' },
{ id: '5', timestamp: new Date(Date.now() - 40000), level: 'warn', source: 'market', message: 'EUR/USD spread widened to 2.3bps' },
{ id: '6', timestamp: new Date(Date.now() - 30000), level: 'info', source: 'system', message: 'Sandbox environment ready' },
];
export const sampleValidation: ValidationIssue[] = [
{ id: '1', severity: 'info', message: 'Graph contains 0 nodes. Add components to begin validation.' },
];
export const sampleAudit: AuditEntry[] = [
{ id: '1', timestamp: new Date(Date.now() - 120000), user: 'system', action: 'SESSION_START', detail: 'Sandbox session initialized' },
{ id: '2', timestamp: new Date(Date.now() - 110000), user: 'system', action: 'ENGINE_LOAD', detail: 'Compliance matrices loaded (47 rules)' },
{ id: '3', timestamp: new Date(Date.now() - 100000), user: 'system', action: 'ENGINE_LOAD', detail: 'Routing engine initialized with 12 venues' },
];
export const sampleSettlement: SettlementItem[] = [
{ id: '1', txId: 'TX-2024-0847', status: 'settled', amount: '1,250,000.00', asset: 'USD', counterparty: 'Acme Corp', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', txId: 'TX-2024-0848', status: 'pending', amount: '500,000.00', asset: 'EUR', counterparty: 'Deutsche Bank', timestamp: new Date(Date.now() - 1800000) },
{ id: '3', txId: 'TX-2024-0849', status: 'in_review', amount: '2,000.00', asset: 'BTC', counterparty: 'BitGo Custody', timestamp: new Date(Date.now() - 900000) },
{ id: '4', txId: 'TX-2024-0850', status: 'awaiting_approval', amount: '750,000.00', asset: 'GBP', counterparty: 'Barclays', timestamp: new Date(Date.now() - 600000) },
];
export const sampleNotifications: Notification[] = [
{ id: '1', title: 'Compliance Update', message: 'New FATF travel rule requirements effective in your jurisdiction', type: 'warning', timestamp: new Date(Date.now() - 600000), read: false },
{ id: '2', title: 'Route Optimization', message: 'New liquidity venue added: Coinbase Prime', type: 'info', timestamp: new Date(Date.now() - 1200000), read: false },
{ id: '3', title: 'Settlement Complete', message: 'TX-2024-0847 settled successfully via SWIFT', type: 'success', timestamp: new Date(Date.now() - 3600000), read: true },
];
export const sampleThreads: ThreadEntry[] = [
{ id: 'thread-1', title: 'Cross-border payment setup', agent: 'Builder', timestamp: new Date(Date.now() - 86400000), messageCount: 12 },
{ id: 'thread-2', title: 'AML compliance review', agent: 'Compliance', timestamp: new Date(Date.now() - 172800000), messageCount: 8 },
{ id: 'thread-3', title: 'SWIFT message generation', agent: 'ISO-20022', timestamp: new Date(Date.now() - 259200000), messageCount: 5 },
];
export const sampleReconciliation = [
{ id: '1', txId: 'TX-2024-0847', internalRef: 'INT-00847', externalRef: 'EXT-SW-4821', status: 'matched', amount: '1,250,000.00', asset: 'USD', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', txId: 'TX-2024-0845', internalRef: 'INT-00845', externalRef: 'EXT-SW-4819', status: 'unmatched', amount: '320,000.00', asset: 'EUR', timestamp: new Date(Date.now() - 7200000) },
{ id: '3', txId: 'TX-2024-0843', internalRef: 'INT-00843', externalRef: 'EXT-CB-1102', status: 'matched', amount: '15.5', asset: 'BTC', timestamp: new Date(Date.now() - 10800000) },
];
export const sampleExceptions = [
{ id: '1', txId: 'TX-2024-0846', type: 'timeout', message: 'Settlement acknowledgement not received within SLA (T+2)', severity: 'error' as const, timestamp: new Date(Date.now() - 5400000) },
{ id: '2', txId: 'TX-2024-0844', type: 'mismatch', message: 'Amount mismatch: expected 500,000.00 EUR, received 499,998.50 EUR', severity: 'warning' as const, timestamp: new Date(Date.now() - 9000000) },
{ id: '3', txId: 'TX-2024-0842', type: 'rejected', message: 'Counterparty rejected: sanctions screening flag on beneficiary', severity: 'error' as const, timestamp: new Date(Date.now() - 14400000) },
];
export const sampleMessageQueue = [
{ id: '1', msgType: 'pain.001', direction: 'outbound' as const, counterparty: 'Deutsche Bank', status: 'sent', timestamp: new Date(Date.now() - 1800000) },
{ id: '2', msgType: 'pacs.008', direction: 'inbound' as const, counterparty: 'Barclays', status: 'received', timestamp: new Date(Date.now() - 2400000) },
{ id: '3', msgType: 'camt.053', direction: 'inbound' as const, counterparty: 'SWIFT', status: 'processing', timestamp: new Date(Date.now() - 3000000) },
];
export const sampleEvents = [
{ id: '1', type: 'NODE_ADDED', detail: 'Fiat Account node added to canvas', timestamp: new Date(Date.now() - 60000) },
{ id: '2', type: 'EDGE_CREATED', detail: 'Connection established: Fiat Account → Transfer', timestamp: new Date(Date.now() - 55000) },
{ id: '3', type: 'VALIDATION_RUN', detail: 'Graph validation completed — 0 errors, 1 warning', timestamp: new Date(Date.now() - 50000) },
{ id: '4', type: 'AGENT_INVOKED', detail: 'Builder Agent queried for routing suggestion', timestamp: new Date(Date.now() - 45000) },
];

3853
src/index.css Normal file

File diff suppressed because it is too large Load Diff

16
src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import './index.css'
import Portal from './Portal'
import { AuthProvider } from './contexts/AuthContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<AuthProvider>
<Portal />
</AuthProvider>
</HashRouter>
</StrictMode>,
)

184
src/pages/AccountsPage.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { 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';
const typeColors: Record<AccountType, string> = {
operating: '#3b82f6', reserve: '#22c55e', custody: '#a855f7', escrow: '#f97316',
settlement: '#06b6d4', nostro: '#eab308', vostro: '#ec4899', collateral: '#6366f1',
treasury: '#14b8a6', crypto_wallet: '#8b5cf6', stablecoin: '#10b981', omnibus: '#64748b',
};
const formatBalance = (amount: number, currency: string) => {
if (currency === 'BTC') return `${amount.toFixed(4)} BTC`;
if (currency === 'USDC') return `$${amount.toLocaleString()}`;
const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '';
if (Math.abs(amount) >= 1_000_000) return `${sym}${(amount / 1_000_000).toFixed(2)}M`;
return `${sym}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
function AccountRow({ account, level = 0 }: { account: Account; level?: number }) {
const [expanded, setExpanded] = useState(false);
const hasChildren = account.subaccounts && account.subaccounts.length > 0;
return (
<>
<div className={`account-table-row level-${level}`} style={{ paddingLeft: `${16 + level * 24}px` }}>
<div className="account-table-name">
{hasChildren ? (
<button className="expand-btn" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="expand-placeholder" />
)}
<span className="account-type-dot" style={{ background: typeColors[account.type] }} />
<div>
<span className="account-name-text">{account.name}</span>
<span className="account-type-label">{account.type.replace('_', ' ')}</span>
</div>
</div>
<div className="account-table-cell currency">{account.currency}</div>
<div className="account-table-cell mono balance">{formatBalance(account.balance, account.currency)}</div>
<div className="account-table-cell mono available">{formatBalance(account.availableBalance, account.currency)}</div>
<div className="account-table-cell">
<span className={`account-status-badge ${account.status}`}>{account.status}</span>
</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.swift && <span className="swift-badge">{account.swift}</span>}
</div>
<div className="account-table-cell">
<span className="mono small">{account.lastActivity.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="account-table-cell actions">
<button className="row-action-btn" title="View Details"><ExternalLink size={12} /></button>
<button className="row-action-btn" title="Copy ID"><Copy size={12} /></button>
<button className="row-action-btn" title="More"><MoreHorizontal size={12} /></button>
</div>
</div>
{expanded && hasChildren && account.subaccounts!.map(sub => (
<AccountRow key={sub.id} account={sub} level={level + 1} />
))}
</>
);
}
export default function AccountsPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [view, setView] = useState<'tree' | 'flat'>('tree');
const allAccounts = view === 'flat'
? sampleAccounts.flatMap(a => [a, ...(a.subaccounts || [])])
: sampleAccounts;
const filtered = allAccounts.filter(a => {
const matchSearch = a.name.toLowerCase().includes(search.toLowerCase()) ||
a.currency.toLowerCase().includes(search.toLowerCase()) ||
a.type.includes(search.toLowerCase());
const matchType = typeFilter === 'all' || a.type === typeFilter;
return matchSearch && matchType && (view === 'flat' || !a.parentId);
});
const totalBalance = sampleAccounts.reduce((sum, a) => {
if (a.currency === 'USD' || a.currency === 'USDC') return sum + a.balance;
if (a.currency === 'EUR') return sum + a.balance * 1.08;
if (a.currency === 'GBP') return sum + a.balance * 1.27;
if (a.currency === 'BTC') return sum + a.balance * 67_000;
return sum;
}, 0);
return (
<div className="accounts-page">
<div className="page-header">
<div>
<h1><Building2 size={24} /> Account Management</h1>
<p className="page-subtitle">Multi-account and subaccount structures with consolidated views</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export</button>
<button className="btn-primary"><Plus size={14} /> New Account</button>
</div>
</div>
{/* Summary Cards */}
<div className="accounts-summary">
<div className="summary-card">
<span className="summary-label">Total Accounts</span>
<span className="summary-value">{sampleAccounts.length + sampleAccounts.reduce((c, a) => c + (a.subaccounts?.length || 0), 0)}</span>
</div>
<div className="summary-card">
<span className="summary-label">Consolidated Balance (USD eq.)</span>
<span className="summary-value">${(totalBalance / 1_000_000).toFixed(2)}M</span>
</div>
<div className="summary-card">
<span className="summary-label">Active</span>
<span className="summary-value green">{sampleAccounts.filter(a => a.status === 'active').length}</span>
</div>
<div className="summary-card">
<span className="summary-label">Frozen</span>
<span className="summary-value orange">{sampleAccounts.filter(a => a.status === 'frozen').length}</span>
</div>
</div>
{/* Toolbar */}
<div className="table-toolbar">
<div className="table-toolbar-left">
<div className="search-input-wrapper">
<Search size={14} />
<input
type="text"
placeholder="Search accounts..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="filter-group">
<Filter size={14} />
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
<option value="all">All Types</option>
<option value="operating">Operating</option>
<option value="treasury">Treasury</option>
<option value="custody">Custody</option>
<option value="settlement">Settlement</option>
<option value="nostro">Nostro</option>
<option value="escrow">Escrow</option>
<option value="collateral">Collateral</option>
<option value="stablecoin">Stablecoin</option>
</select>
</div>
</div>
<div className="table-toolbar-right">
<div className="view-toggle">
<button className={view === 'tree' ? 'active' : ''} onClick={() => setView('tree')}>Tree</button>
<button className={view === 'flat' ? 'active' : ''} onClick={() => setView('flat')}>Flat</button>
</div>
</div>
</div>
{/* Account Table */}
<div className="account-table">
<div className="account-table-header">
<div className="account-table-name">Account</div>
<div className="account-table-cell currency">Currency</div>
<div className="account-table-cell balance">Balance</div>
<div className="account-table-cell available">Available</div>
<div className="account-table-cell">Status</div>
<div className="account-table-cell identifier">Identifier</div>
<div className="account-table-cell">Last Activity</div>
<div className="account-table-cell actions" />
</div>
<div className="account-table-body">
{filtered.map(acc => (
<AccountRow key={acc.id} account={acc} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from 'react';
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck } from 'lucide-react';
import { complianceAlerts } from '../data/portalData';
const severityColors: Record<string, string> = {
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6',
};
const statusColors: Record<string, string> = {
open: '#ef4444', acknowledged: '#eab308', resolved: '#22c55e',
};
const complianceMetrics = [
{ label: 'KYC Verified', value: '142', total: '145', pct: 98, color: '#22c55e' },
{ label: 'AML Screening', value: 'Active', total: '47 rules', pct: 100, color: '#3b82f6' },
{ label: 'Sanctions Check', value: 'Current', total: 'OFAC/EU/UN', pct: 100, color: '#a855f7' },
{ label: 'Travel Rule', value: '98.5%', total: 'compliant', pct: 98.5, color: '#14b8a6' },
];
const regulatoryFrameworks = [
{ name: 'FATF Travel Rule', status: 'compliant', lastReview: '2024-03-15', nextReview: '2024-06-15' },
{ name: 'MiCA (EU)', status: 'compliant', lastReview: '2024-02-28', nextReview: '2024-05-28' },
{ name: 'Bank Secrecy Act (US)', status: 'compliant', lastReview: '2024-03-01', nextReview: '2024-06-01' },
{ name: 'FCA Regulations (UK)', status: 'review_needed', lastReview: '2024-01-15', nextReview: '2024-04-15' },
{ name: 'MAS Guidelines (SG)', status: 'compliant', lastReview: '2024-03-10', nextReview: '2024-06-10' },
{ name: 'JFSA Standards (JP)', status: 'compliant', lastReview: '2024-02-20', nextReview: '2024-05-20' },
];
export default function CompliancePage() {
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const filtered = complianceAlerts.filter(a => {
const matchSev = severityFilter === 'all' || a.severity === severityFilter;
const matchStatus = statusFilter === 'all' || a.status === statusFilter;
return matchSev && matchStatus;
});
const openCount = complianceAlerts.filter(a => a.status === 'open').length;
const criticalCount = complianceAlerts.filter(a => a.severity === 'critical' && a.status !== 'resolved').length;
return (
<div className="compliance-page">
<div className="page-header">
<div>
<h1><Shield size={24} /> Compliance & Risk Management</h1>
<p className="page-subtitle">Regulatory compliance monitoring, AML/KYC oversight, and risk controls</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export Report</button>
<button className="btn-primary"><UserCheck size={14} /> Run Full Scan</button>
</div>
</div>
{/* Compliance Metrics */}
<div className="compliance-metrics">
{complianceMetrics.map(m => (
<div key={m.label} className="metric-card">
<div className="metric-header">
<span className="metric-label">{m.label}</span>
<CheckCircle2 size={14} color={m.color} />
</div>
<div className="metric-value" style={{ color: m.color }}>{m.value}</div>
<div className="metric-sub">{m.total}</div>
<div className="metric-bar">
<div className="metric-bar-fill" style={{ width: `${m.pct}%`, background: m.color }} />
</div>
</div>
))}
</div>
<div className="compliance-grid">
{/* Alerts */}
<div className="dashboard-card alerts-table-card">
<div className="card-header">
<h3><AlertTriangle size={16} /> Active Alerts ({openCount} open, {criticalCount} critical)</h3>
<div className="card-header-actions">
<div className="filter-group">
<Filter size={12} />
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}>
<option value="all">All Severity</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="filter-group">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="open">Open</option>
<option value="acknowledged">Acknowledged</option>
<option value="resolved">Resolved</option>
</select>
</div>
</div>
</div>
<div className="alerts-table">
{filtered.map(alert => (
<div key={alert.id} className="alert-table-row">
<span className="alert-sev-badge" style={{ background: severityColors[alert.severity] + '20', color: severityColors[alert.severity], borderColor: severityColors[alert.severity] + '40' }}>
{alert.severity.toUpperCase()}
</span>
<span className="alert-cat">{alert.category}</span>
<span className="alert-msg">{alert.message}</span>
<span className="alert-status-badge" style={{ color: statusColors[alert.status] }}>
{alert.status === 'resolved' ? <CheckCircle2 size={10} /> : alert.status === 'acknowledged' ? <Eye size={10} /> : <Clock size={10} />}
{alert.status}
</span>
<span className="alert-time mono">
{alert.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{alert.assignedTo && <span className="alert-assigned">{alert.assignedTo}</span>}
</div>
))}
</div>
</div>
{/* Regulatory Frameworks */}
<div className="dashboard-card regulatory-card">
<div className="card-header">
<h3><Shield size={16} /> Regulatory Frameworks</h3>
</div>
<div className="regulatory-list">
{regulatoryFrameworks.map(fw => (
<div key={fw.name} className="regulatory-row">
<div className="regulatory-info">
<span className="regulatory-name">{fw.name}</span>
<span className={`regulatory-status ${fw.status}`}>
{fw.status === 'compliant' ? <CheckCircle2 size={10} /> : <AlertTriangle size={10} />}
{fw.status.replace('_', ' ')}
</span>
</div>
<div className="regulatory-dates">
<span className="small">Last: {fw.lastReview}</span>
<span className="small">Next: {fw.nextReview}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

304
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,304 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
TrendingUp, TrendingDown, DollarSign, Activity, AlertTriangle, Clock,
ArrowUpRight, ArrowDownRight, BarChart3, PieChart, Zap, Building2,
Landmark, FileText, Shield, CheckSquare, ChevronRight, RefreshCw
} from 'lucide-react';
import { financialSummary, sampleAccounts, treasuryPositions, complianceAlerts, recentActivity, portalModules } from '../data/portalData';
const formatCurrency = (amount: number, currency = 'USD') => {
if (Math.abs(amount) >= 1_000_000_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000_000_000).toFixed(2)}B`;
if (Math.abs(amount) >= 1_000_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000_000).toFixed(2)}M`;
if (Math.abs(amount) >= 1_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000).toFixed(1)}K`;
return `${currency === 'USD' ? '$' : ''}${amount.toFixed(2)}`;
};
const moduleIcons: Record<string, typeof Zap> = {
'dashboard': BarChart3,
'transaction-builder': Zap,
'accounts': Building2,
'treasury': Landmark,
'reporting': FileText,
'compliance': Shield,
'settlements': CheckSquare,
};
const statusColors: Record<string, string> = {
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
info: '#3b82f6',
};
const severityColors: Record<string, string> = {
critical: '#ef4444',
high: '#f97316',
medium: '#eab308',
low: '#3b82f6',
};
export default function DashboardPage() {
const navigate = useNavigate();
const [timeRange, setTimeRange] = useState<'1D' | '1W' | '1M' | '3M' | 'YTD'>('1D');
const totalPnL = financialSummary.unrealizedPnL + financialSummary.realizedPnL;
const pnlPositive = totalPnL >= 0;
const assetAllocation = [
{ label: 'Fixed Income', value: 83_900_000, color: '#3b82f6', pct: 39 },
{ label: 'Equities', value: 45_200_000, color: '#22c55e', pct: 21 },
{ label: 'Digital Assets', value: 20_425_000, color: '#a855f7', pct: 10 },
{ label: 'FX', value: 20_250_000, color: '#eab308', pct: 9 },
{ label: 'Commodities', value: 11_500_000, color: '#f97316', pct: 5 },
{ label: 'Cash & Equivalents', value: 33_175_000, color: '#6b7280', pct: 16 },
];
const openAlerts = complianceAlerts.filter(a => a.status !== 'resolved');
return (
<div className="dashboard-page">
<div className="dashboard-header">
<div className="dashboard-header-left">
<h1>Portfolio Overview</h1>
<p className="dashboard-subtitle">Solace Bank Group PLC Consolidated View</p>
</div>
<div className="dashboard-header-right">
<div className="time-range-selector">
{(['1D', '1W', '1M', '3M', 'YTD'] as const).map(range => (
<button
key={range}
className={`time-range-btn ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range}
</button>
))}
</div>
<button className="refresh-btn">
<RefreshCw size={14} />
<span>Refresh</span>
</button>
</div>
</div>
{/* KPI Cards Row */}
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Total Assets (AUM)</span>
<DollarSign size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.totalAssets)}</div>
<div className="kpi-change positive">
<ArrowUpRight size={12} />
<span>+2.3% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Net Position</span>
<Activity size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.netPosition)}</div>
<div className="kpi-change positive">
<ArrowUpRight size={12} />
<span>+1.8% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Total P&L</span>
{pnlPositive ? <TrendingUp size={16} className="kpi-icon positive" /> : <TrendingDown size={16} className="kpi-icon negative" />}
</div>
<div className={`kpi-value ${pnlPositive ? 'positive' : 'negative'}`}>
{pnlPositive ? '+' : ''}{formatCurrency(totalPnL)}
</div>
<div className="kpi-sub">
<span>Realized: {formatCurrency(financialSummary.realizedPnL)}</span>
<span>Unrealized: {formatCurrency(financialSummary.unrealizedPnL)}</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Daily Volume</span>
<BarChart3 size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.dailyVolume)}</div>
<div className="kpi-change negative">
<ArrowDownRight size={12} />
<span>-5.1% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Pending Settlements</span>
<Clock size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.pendingSettlements)}</div>
<div className="kpi-sub">
<span>3 DVP · 1 PVP · 2 FOP</span>
</div>
</div>
<div className="kpi-card alert-card">
<div className="kpi-header">
<span className="kpi-label">Active Alerts</span>
<AlertTriangle size={16} className="kpi-icon warning" />
</div>
<div className="kpi-value">{openAlerts.length}</div>
<div className="kpi-sub">
<span style={{ color: '#ef4444' }}>{openAlerts.filter(a => a.severity === 'critical').length} critical</span>
<span style={{ color: '#f97316' }}>{openAlerts.filter(a => a.severity === 'high').length} high</span>
</div>
</div>
</div>
<div className="dashboard-grid">
{/* Asset Allocation */}
<div className="dashboard-card asset-allocation">
<div className="card-header">
<h3><PieChart size={16} /> Asset Allocation</h3>
</div>
<div className="allocation-chart">
<div className="allocation-bar">
{assetAllocation.map(a => (
<div
key={a.label}
className="allocation-segment"
style={{ width: `${a.pct}%`, background: a.color }}
title={`${a.label}: ${a.pct}%`}
/>
))}
</div>
<div className="allocation-legend">
{assetAllocation.map(a => (
<div key={a.label} className="legend-item">
<span className="legend-dot" style={{ background: a.color }} />
<span className="legend-label">{a.label}</span>
<span className="legend-value">{formatCurrency(a.value)}</span>
<span className="legend-pct">{a.pct}%</span>
</div>
))}
</div>
</div>
</div>
{/* Top Positions */}
<div className="dashboard-card positions-card">
<div className="card-header">
<h3><TrendingUp size={16} /> Top Positions</h3>
<button className="card-action" onClick={() => navigate('/treasury')}>View All <ChevronRight size={12} /></button>
</div>
<div className="positions-table">
<div className="positions-header">
<span>Instrument</span>
<span>Market Value</span>
<span>P&L</span>
</div>
{treasuryPositions.slice(0, 6).map(pos => (
<div key={pos.id} className="position-row">
<div className="position-name">
<span className="position-asset-class">{pos.assetClass}</span>
<span>{pos.instrument}</span>
</div>
<span className="mono">{formatCurrency(pos.marketValue)}</span>
<span className={`mono ${pos.unrealizedPnL >= 0 ? 'positive' : 'negative'}`}>
{pos.unrealizedPnL >= 0 ? '+' : ''}{formatCurrency(pos.unrealizedPnL)}
</span>
</div>
))}
</div>
</div>
{/* Accounts Overview */}
<div className="dashboard-card accounts-overview">
<div className="card-header">
<h3><Building2 size={16} /> Accounts</h3>
<button className="card-action" onClick={() => navigate('/accounts')}>Manage <ChevronRight size={12} /></button>
</div>
<div className="accounts-list">
{sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => (
<div key={acc.id} className="account-row">
<div className="account-info">
<span className={`account-type-badge ${acc.type}`}>{acc.type}</span>
<span className="account-name">{acc.name}</span>
</div>
<div className="account-balance">
<span className="mono">
{acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)}
</span>
<span className="account-currency">{acc.currency}</span>
</div>
</div>
))}
</div>
</div>
{/* Compliance Alerts */}
<div className="dashboard-card compliance-card">
<div className="card-header">
<h3><Shield size={16} /> Compliance Alerts</h3>
<button className="card-action" onClick={() => navigate('/compliance')}>View All <ChevronRight size={12} /></button>
</div>
<div className="alerts-list">
{complianceAlerts.filter(a => a.status !== 'resolved').slice(0, 4).map(alert => (
<div key={alert.id} className="alert-row">
<span className="alert-severity" style={{ color: severityColors[alert.severity] }}>
{alert.severity.toUpperCase()}
</span>
<span className="alert-category">{alert.category}</span>
<span className="alert-message">{alert.message}</span>
</div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="dashboard-card activity-card">
<div className="card-header">
<h3><Activity size={16} /> Recent Activity</h3>
</div>
<div className="activity-list">
{recentActivity.map(item => (
<div key={item.id} className="activity-row">
<span className="activity-dot" style={{ background: statusColors[item.status] }} />
<div className="activity-content">
<span className="activity-action">{item.action}</span>
<span className="activity-detail">{item.detail}</span>
</div>
<span className="activity-time">
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
))}
</div>
</div>
{/* Quick Access Modules */}
<div className="dashboard-card modules-card">
<div className="card-header">
<h3><Zap size={16} /> Quick Access</h3>
</div>
<div className="modules-grid">
{portalModules.filter(m => m.id !== 'dashboard').map(mod => {
const Icon = moduleIcons[mod.id] || Zap;
return (
<button
key={mod.id}
className="module-card"
onClick={() => navigate(mod.path)}
disabled={mod.status !== 'active'}
>
<Icon size={20} />
<span className="module-name">{mod.name}</span>
<span className="module-desc">{mod.description}</span>
{mod.status === 'coming_soon' && <span className="module-badge">Coming Soon</span>}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

189
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,189 @@
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Shield, Wallet, ArrowRight, Globe, Lock, Zap, TrendingUp, Building2, ChevronRight } from 'lucide-react';
export default function LoginPage() {
const { connectWallet, loading, error } = useAuth();
const [connecting, setConnecting] = useState<string | null>(null);
const handleConnect = async (provider: 'metamask' | 'walletconnect' | 'coinbase') => {
setConnecting(provider);
await connectWallet(provider);
setConnecting(null);
};
return (
<div className="login-page">
<div className="login-bg-grid" />
<div className="login-bg-glow" />
<div className="login-container">
<div className="login-left">
<div className="login-brand">
<div className="login-logo">
<Building2 size={32} />
<div>
<h1>Solace Bank Group</h1>
<span className="login-plc">PLC</span>
</div>
</div>
<p className="login-tagline">Enterprise Treasury Management Portal</p>
</div>
<div className="login-features">
<div className="login-feature">
<div className="login-feature-icon">
<TrendingUp size={20} />
</div>
<div>
<h3>Multi-Asset Treasury</h3>
<p>Consolidated views across fiat, digital assets, securities, and commodities</p>
</div>
</div>
<div className="login-feature">
<div className="login-feature-icon">
<Shield size={20} />
</div>
<div>
<h3>Regulatory Compliance</h3>
<p>IPSAS, US GAAP, and IFRS compliant reporting frameworks</p>
</div>
</div>
<div className="login-feature">
<div className="login-feature-icon">
<Globe size={20} />
</div>
<div>
<h3>Global Settlement</h3>
<p>Cross-border payment orchestration with real-time settlement tracking</p>
</div>
</div>
<div className="login-feature">
<div className="login-feature-icon">
<Lock size={20} />
</div>
<div>
<h3>Web3 Security</h3>
<p>Cryptographic wallet authentication with enterprise-grade access controls</p>
</div>
</div>
</div>
<div className="login-compliance-badges">
<span className="compliance-badge">IPSAS</span>
<span className="compliance-badge">US GAAP</span>
<span className="compliance-badge">IFRS</span>
<span className="compliance-badge">ISO 20022</span>
<span className="compliance-badge">SOC 2</span>
</div>
</div>
<div className="login-right">
<div className="login-card">
<div className="login-card-header">
<Wallet size={24} />
<h2>Connect Wallet</h2>
<p>Authenticate with your Web3 wallet to access the portal</p>
</div>
{error && (
<div className="login-error">
<span>{error}</span>
</div>
)}
<div className="login-wallets">
<button
className={`wallet-option ${connecting === 'metamask' ? 'connecting' : ''}`}
onClick={() => handleConnect('metamask')}
disabled={loading}
>
<div className="wallet-option-left">
<div className="wallet-icon metamask">
<span>🦊</span>
</div>
<div>
<span className="wallet-name">MetaMask</span>
<span className="wallet-desc">Browser extension wallet</span>
</div>
</div>
{connecting === 'metamask' ? (
<div className="wallet-spinner" />
) : (
<ChevronRight size={16} className="wallet-arrow" />
)}
</button>
<button
className={`wallet-option ${connecting === 'walletconnect' ? 'connecting' : ''}`}
onClick={() => handleConnect('walletconnect')}
disabled={loading}
>
<div className="wallet-option-left">
<div className="wallet-icon walletconnect">
<span>🔗</span>
</div>
<div>
<span className="wallet-name">WalletConnect</span>
<span className="wallet-desc">Scan QR code to connect</span>
</div>
</div>
{connecting === 'walletconnect' ? (
<div className="wallet-spinner" />
) : (
<ChevronRight size={16} className="wallet-arrow" />
)}
</button>
<button
className={`wallet-option ${connecting === 'coinbase' ? 'connecting' : ''}`}
onClick={() => handleConnect('coinbase')}
disabled={loading}
>
<div className="wallet-option-left">
<div className="wallet-icon coinbase">
<span>🔵</span>
</div>
<div>
<span className="wallet-name">Coinbase Wallet</span>
<span className="wallet-desc">Coinbase self-custody wallet</span>
</div>
</div>
{connecting === 'coinbase' ? (
<div className="wallet-spinner" />
) : (
<ChevronRight size={16} className="wallet-arrow" />
)}
</button>
</div>
<div className="login-divider">
<span>or</span>
</div>
<button
className="login-demo-btn"
onClick={() => handleConnect('metamask')}
disabled={loading}
>
<Zap size={16} />
<span>Enter Demo Mode</span>
<ArrowRight size={14} />
</button>
<p className="login-terms">
By connecting, you agree to the Terms of Service and acknowledge
that Solace Bank Group PLC processes authentication via
cryptographic signature verification.
</p>
</div>
<div className="login-security-note">
<Lock size={12} />
<span>End-to-end encrypted · No private keys stored · SOC 2 Type II certified</span>
</div>
</div>
</div>
</div>
);
}

180
src/pages/ReportingPage.tsx Normal file
View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import { FileText, Download, Filter, Plus, Eye, Clock, CheckCircle2, AlertTriangle, Send } from 'lucide-react';
import { reportConfigs } from '../data/portalData';
import type { ReportingStandard } from '../types/portal';
const standardColors: Record<ReportingStandard, string> = {
IPSAS: '#a855f7',
US_GAAP: '#3b82f6',
IFRS: '#22c55e',
};
const statusIcons: Record<string, typeof Clock> = {
draft: Clock,
generated: AlertTriangle,
reviewed: Eye,
published: CheckCircle2,
};
const statusColors: Record<string, string> = {
draft: '#6b7280',
generated: '#eab308',
reviewed: '#3b82f6',
published: '#22c55e',
};
export default function ReportingPage() {
const [standardFilter, setStandardFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [activeStandard, setActiveStandard] = useState<ReportingStandard>('IFRS');
const filtered = reportConfigs.filter(r => {
const matchStandard = standardFilter === 'all' || r.standard === standardFilter;
const matchType = typeFilter === 'all' || r.type === typeFilter;
return matchStandard && matchType;
});
const standardDetails: Record<ReportingStandard, { full: string; description: string; keyStatements: string[]; jurisdiction: string }> = {
IPSAS: {
full: 'International Public Sector Accounting Standards',
description: 'Accrual-based accounting standards for public sector entities, issued by the IPSASB. Ensures transparency and accountability in government financial reporting.',
keyStatements: ['Statement of Financial Position', 'Statement of Financial Performance', 'Statement of Changes in Net Assets', 'Cash Flow Statement', 'Budget Comparison Statement'],
jurisdiction: 'International (Public Sector)',
},
US_GAAP: {
full: 'United States Generally Accepted Accounting Principles',
description: 'Comprehensive accounting framework issued by FASB, mandatory for US public companies and widely adopted by financial institutions.',
keyStatements: ['Balance Sheet', 'Income Statement', 'Statement of Cash Flows', 'Statement of Stockholders\' Equity', 'Notes to Financial Statements'],
jurisdiction: 'United States',
},
IFRS: {
full: 'International Financial Reporting Standards',
description: 'Global accounting standards issued by the IASB, adopted by 140+ jurisdictions. Principle-based framework for transparent financial reporting.',
keyStatements: ['Statement of Financial Position', 'Statement of Profit or Loss', 'Statement of Comprehensive Income', 'Statement of Cash Flows', 'Statement of Changes in Equity'],
jurisdiction: 'International (140+ jurisdictions)',
},
};
const detail = standardDetails[activeStandard];
return (
<div className="reporting-page">
<div className="page-header">
<div>
<h1><FileText size={24} /> Financial Reporting</h1>
<p className="page-subtitle">IPSAS, US GAAP, and IFRS compliant reporting frameworks</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export All</button>
<button className="btn-primary"><Plus size={14} /> Generate Report</button>
</div>
</div>
{/* Standards Overview */}
<div className="standards-tabs">
{(['IPSAS', 'US_GAAP', 'IFRS'] as ReportingStandard[]).map(std => (
<button
key={std}
className={`standard-tab ${activeStandard === std ? 'active' : ''}`}
onClick={() => setActiveStandard(std)}
style={activeStandard === std ? { borderColor: standardColors[std], color: standardColors[std] } : {}}
>
<span className="standard-dot" style={{ background: standardColors[std] }} />
{std.replace('_', ' ')}
<span className="standard-count">{reportConfigs.filter(r => r.standard === std).length}</span>
</button>
))}
</div>
<div className="standard-detail-card" style={{ borderColor: standardColors[activeStandard] + '40' }}>
<div className="standard-detail-header">
<div>
<h3 style={{ color: standardColors[activeStandard] }}>{activeStandard.replace('_', ' ')}</h3>
<p className="standard-full-name">{detail.full}</p>
</div>
<span className="jurisdiction-badge">{detail.jurisdiction}</span>
</div>
<p className="standard-description">{detail.description}</p>
<div className="key-statements">
<span className="key-statements-label">Key Financial Statements:</span>
<div className="statements-list">
{detail.keyStatements.map(stmt => (
<span key={stmt} className="statement-badge">{stmt}</span>
))}
</div>
</div>
</div>
{/* Reports Table */}
<div className="dashboard-card reports-card">
<div className="card-header">
<h3>Generated Reports</h3>
<div className="card-header-actions">
<div className="filter-group">
<Filter size={12} />
<select value={standardFilter} onChange={e => setStandardFilter(e.target.value)}>
<option value="all">All Standards</option>
<option value="IPSAS">IPSAS</option>
<option value="US_GAAP">US GAAP</option>
<option value="IFRS">IFRS</option>
</select>
</div>
<div className="filter-group">
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
<option value="all">All Types</option>
<option value="balance_sheet">Balance Sheet</option>
<option value="income_statement">Income Statement</option>
<option value="cash_flow">Cash Flow</option>
<option value="trial_balance">Trial Balance</option>
<option value="regulatory">Regulatory</option>
<option value="position_summary">Position Summary</option>
<option value="risk_exposure">Risk Exposure</option>
<option value="compliance_summary">Compliance Summary</option>
</select>
</div>
</div>
</div>
<div className="reports-table">
<div className="reports-table-header">
<span>Report Name</span>
<span>Standard</span>
<span>Type</span>
<span>Period</span>
<span>Status</span>
<span>Generated</span>
<span>By</span>
<span>Actions</span>
</div>
{filtered.map(report => {
const StatusIcon = statusIcons[report.status] || Clock;
return (
<div key={report.id} className="reports-table-row">
<span className="report-name">{report.name}</span>
<span>
<span className="standard-badge" style={{ color: standardColors[report.standard], borderColor: standardColors[report.standard] + '40' }}>
{report.standard.replace('_', ' ')}
</span>
</span>
<span className="report-type">{report.type.replace(/_/g, ' ')}</span>
<span className="report-period">{report.period}</span>
<span>
<span className="report-status" style={{ color: statusColors[report.status] }}>
<StatusIcon size={12} />
{report.status}
</span>
</span>
<span className="mono small">{report.generatedAt ? report.generatedAt.toLocaleDateString() : '—'}</span>
<span className="small">{report.generatedBy || '—'}</span>
<span className="report-actions">
<button className="row-action-btn" title="View"><Eye size={12} /></button>
<button className="row-action-btn" title="Download"><Download size={12} /></button>
<button className="row-action-btn" title="Submit"><Send size={12} /></button>
</span>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { CheckSquare, Filter, Download, Clock, CheckCircle2, XCircle, ArrowUpDown } from 'lucide-react';
import { settlementRecords } from '../data/portalData';
const statusColors: Record<string, string> = {
pending: '#eab308', matched: '#3b82f6', affirmed: '#a855f7',
settled: '#22c55e', failed: '#ef4444', cancelled: '#6b7280',
};
const statusIcons: Record<string, typeof Clock> = {
pending: Clock, matched: CheckCircle2, affirmed: CheckCircle2,
settled: CheckCircle2, failed: XCircle, cancelled: XCircle,
};
const typeColors: Record<string, string> = {
DVP: '#3b82f6', FOP: '#22c55e', PVP: '#a855f7', internal: '#6b7280',
};
const formatCurrency = (amount: number, currency: string) => {
const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : currency === 'JPY' ? '¥' : '';
if (Math.abs(amount) >= 1_000_000) return `${sym}${(amount / 1_000_000).toFixed(2)}M`;
return `${sym}${amount.toLocaleString()}`;
};
export default function SettlementsPage() {
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [sortBy, setSortBy] = useState<'date' | 'amount'>('date');
const filtered = settlementRecords
.filter(s => (statusFilter === 'all' || s.status === statusFilter) && (typeFilter === 'all' || s.type === typeFilter))
.sort((a, b) => sortBy === 'date' ? b.settlementDate.getTime() - a.settlementDate.getTime() : b.amount - a.amount);
const pendingCount = settlementRecords.filter(s => ['pending', 'matched', 'affirmed'].includes(s.status)).length;
const settledCount = settlementRecords.filter(s => s.status === 'settled').length;
const failedCount = settlementRecords.filter(s => s.status === 'failed').length;
const totalPending = settlementRecords
.filter(s => ['pending', 'matched', 'affirmed'].includes(s.status))
.reduce((sum, s) => sum + s.amount, 0);
return (
<div className="settlements-page">
<div className="page-header">
<div>
<h1><CheckSquare size={24} /> Settlement & Clearing</h1>
<p className="page-subtitle">Settlement lifecycle tracking, DVP/FOP/PVP operations, and CSD integration</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export</button>
</div>
</div>
<div className="settlements-summary">
<div className="summary-card">
<span className="summary-label">Pending</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-value green">{settledCount}</span>
</div>
<div className="summary-card">
<span className="summary-label">Failed</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>
</div>
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Settlement Queue</h3>
<div className="card-header-actions">
<div className="filter-group">
<Filter size={12} />
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="matched">Matched</option>
<option value="affirmed">Affirmed</option>
<option value="settled">Settled</option>
<option value="failed">Failed</option>
</select>
</div>
<div className="filter-group">
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
<option value="all">All Types</option>
<option value="DVP">DVP</option>
<option value="FOP">FOP</option>
<option value="PVP">PVP</option>
<option value="internal">Internal</option>
</select>
</div>
<div className="filter-group">
<ArrowUpDown size={12} />
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'date' | 'amount')}>
<option value="date">Sort by Date</option>
<option value="amount">Sort by Amount</option>
</select>
</div>
</div>
</div>
<div className="settlements-table">
<div className="settlements-table-header">
<span>TX ID</span>
<span>Type</span>
<span>Status</span>
<span>Amount</span>
<span>Currency</span>
<span>Counterparty</span>
<span>Settlement Date</span>
<span>Value Date</span>
<span>CSD</span>
</div>
{filtered.map(record => {
const StatusIcon = statusIcons[record.status] || Clock;
return (
<div key={record.id} className="settlements-table-row">
<span className="mono">{record.txId}</span>
<span>
<span className="type-badge" style={{ color: typeColors[record.type], borderColor: typeColors[record.type] + '40' }}>
{record.type}
</span>
</span>
<span>
<span className="settlement-status" style={{ color: statusColors[record.status] }}>
<StatusIcon size={12} />
{record.status}
</span>
</span>
<span className="mono">{formatCurrency(record.amount, record.currency)}</span>
<span>{record.currency}</span>
<span>{record.counterparty}</span>
<span className="mono small">{record.settlementDate.toLocaleDateString()}</span>
<span className="mono small">{record.valueDate.toLocaleDateString()}</span>
<span className="csd-badge">{record.csd || '—'}</span>
</div>
);
})}
</div>
</div>
</div>
);
}

153
src/pages/TreasuryPage.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useState } from 'react';
import { Landmark, TrendingUp, TrendingDown, Download, Filter, ArrowUpDown, RefreshCw } from 'lucide-react';
import { treasuryPositions, cashForecasts } from '../data/portalData';
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 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>
<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>
);
}

108
src/types/index.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { Node, Edge } from '@xyflow/react';
export interface ComponentItem {
id: string;
label: string;
category: string;
icon: string;
description: string;
color: string;
inputs?: string[];
outputs?: string[];
engines?: string[];
}
export interface ChatMessage {
id: string;
agent: string;
content: string;
timestamp: Date;
type: 'user' | 'agent' | 'system';
}
export interface TerminalEntry {
id: string;
timestamp: Date;
level: 'info' | 'warn' | 'error' | 'success';
source: string;
message: string;
}
export interface ValidationIssue {
id: string;
severity: 'error' | 'warning' | 'info';
node?: string;
field?: string;
message: string;
}
export interface AuditEntry {
id: string;
timestamp: Date;
user: string;
action: string;
detail: string;
}
export interface SettlementItem {
id: string;
txId: string;
status: 'pending' | 'in_review' | 'awaiting_approval' | 'dispatched' | 'partially_settled' | 'settled' | 'failed';
amount: string;
asset: string;
counterparty: string;
timestamp: Date;
}
export interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: Date;
read: boolean;
}
export interface ThreadEntry {
id: string;
title: string;
agent: Agent;
timestamp: Date;
messageCount: number;
}
export interface TransactionTab {
id: string;
name: string;
nodes: Node[];
edges: Edge[];
}
export interface HistoryEntry {
nodes: Node[];
edges: Edge[];
}
export type TransactionNode = Node<{
label: string;
category: string;
icon: string;
color: string;
status?: 'valid' | 'warning' | 'error';
}>;
export type TransactionEdge = Edge<{
animated?: boolean;
}>;
export type PanelSide = 'left' | 'right' | 'bottom';
export type SessionMode = 'Sandbox' | 'Simulate' | 'Live' | 'Compliance Review';
export type ActivityTab = 'builder' | 'assets' | 'templates' | 'compliance' | 'routes' | 'protocols' | 'agents' | 'terminal' | 'audit' | 'settings';
export type BottomTab = 'terminal' | 'validation' | '800system' | 'settlement' | 'audit' | 'messages' | 'events' | 'reconciliation' | 'exceptions';
export type Agent = 'Builder' | 'Compliance' | 'Routing' | 'ISO-20022' | 'Settlement' | 'Risk' | 'Documentation';
export type ConversationScope = 'current-node' | 'current-flow' | 'full-transaction' | 'terminal' | 'compliance';

143
src/types/portal.ts Normal file
View File

@@ -0,0 +1,143 @@
export interface WalletInfo {
address: string;
chainId: number;
balance: string;
ensName?: string;
provider: 'metamask' | 'walletconnect' | 'coinbase' | 'injected';
}
export interface AuthState {
isAuthenticated: boolean;
wallet: WalletInfo | null;
user: PortalUser | null;
loading: boolean;
}
export interface PortalUser {
id: string;
displayName: string;
role: UserRole;
permissions: Permission[];
institution: string;
department: string;
lastLogin: Date;
walletAddress: string;
}
export type UserRole = 'admin' | 'treasurer' | 'analyst' | 'compliance_officer' | 'auditor' | 'viewer';
export type Permission =
| 'accounts.view' | 'accounts.manage' | 'accounts.create'
| 'transactions.view' | 'transactions.create' | 'transactions.approve' | 'transactions.execute'
| 'treasury.view' | 'treasury.manage' | 'treasury.rebalance'
| 'compliance.view' | 'compliance.manage' | 'compliance.override'
| 'reports.view' | 'reports.generate' | 'reports.export'
| 'settlements.view' | 'settlements.approve'
| 'admin.users' | 'admin.settings' | 'admin.audit';
export interface Account {
id: string;
name: string;
type: AccountType;
currency: string;
balance: number;
availableBalance: number;
status: 'active' | 'frozen' | 'closed' | 'pending';
parentId?: string;
institution: string;
iban?: string;
swift?: string;
walletAddress?: string;
lastActivity: Date;
subaccounts?: Account[];
}
export type AccountType =
| 'operating' | 'reserve' | 'custody' | 'escrow'
| 'settlement' | 'nostro' | 'vostro' | 'collateral'
| 'treasury' | 'crypto_wallet' | 'stablecoin' | 'omnibus';
export interface FinancialSummary {
totalAssets: number;
totalLiabilities: number;
netPosition: number;
unrealizedPnL: number;
realizedPnL: number;
pendingSettlements: number;
dailyVolume: number;
currency: string;
}
export interface TreasuryPosition {
id: string;
assetClass: string;
instrument: string;
quantity: number;
marketValue: number;
costBasis: number;
unrealizedPnL: number;
currency: string;
custodian: string;
maturityDate?: Date;
}
export interface CashForecast {
date: Date;
projected: number;
actual?: number;
variance?: number;
currency: string;
}
export type ReportingStandard = 'IPSAS' | 'US_GAAP' | 'IFRS';
export interface ReportConfig {
id: string;
name: string;
standard: ReportingStandard;
type: ReportType;
period: ReportPeriod;
status: 'draft' | 'generated' | 'reviewed' | 'published';
generatedAt?: Date;
generatedBy?: string;
}
export type ReportType =
| 'balance_sheet' | 'income_statement' | 'cash_flow'
| 'trial_balance' | 'general_ledger' | 'regulatory'
| 'position_summary' | 'risk_exposure' | 'compliance_summary';
export type ReportPeriod = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'annual' | 'custom';
export interface PortalModule {
id: string;
name: string;
icon: string;
description: string;
path: string;
requiredPermission: Permission;
status: 'active' | 'coming_soon' | 'maintenance';
}
export interface ComplianceAlert {
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
category: string;
message: string;
timestamp: Date;
status: 'open' | 'acknowledged' | 'resolved';
assignedTo?: string;
}
export interface SettlementRecord {
id: string;
txId: string;
type: 'DVP' | 'FOP' | 'PVP' | 'internal';
status: 'pending' | 'matched' | 'affirmed' | 'settled' | 'failed' | 'cancelled';
amount: number;
currency: string;
counterparty: string;
settlementDate: Date;
valueDate: Date;
csd?: string;
}

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})