1 Commits

Author SHA1 Message Date
Devin AI
e83107d71f 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 10:25:56 -07:00
3 changed files with 71 additions and 110 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useReducer } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react'; import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
import TitleBar from './components/TitleBar'; import TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar'; import ActivityBar from './components/ActivityBar';
@@ -66,75 +66,44 @@ export default function App() {
})); }));
}, [activeTransactionId]); }, [activeTransactionId]);
// Undo/redo — per-tab history to prevent cross-tab state corruption // Undo/redo
type HistoryState = { entries: HistoryEntry[]; index: number }; const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]);
type PerTabHistoryState = Record<string, HistoryState>; const [historyIndex, setHistoryIndex] = useState(0);
type HistoryAction = const skipHistoryRef = useRef(false);
| { 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 pushHistory = useCallback((n: Node[], e: Edge[]) => {
if (skipHistoryRef.current) { skipHistoryRef.current = false; return; }
const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => { setHistory(prev => {
const tabState = state[action.tabId] || defaultHistory; const trimmed = prev.slice(0, historyIndex + 1);
switch (action.type) { const entry = { nodes: JSON.parse(JSON.stringify(n)), edges: JSON.parse(JSON.stringify(e)) };
case 'push': { const next = [...trimmed, entry];
const trimmed = tabState.entries.slice(0, tabState.index + 1); if (next.length > 50) next.shift();
const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) }; return next;
const next = [...trimmed, entry]; });
let newIndex = trimmed.length; setHistoryIndex(prev => Math.min(prev + 1, 50));
if (next.length > 50) { }, [historyIndex]);
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 undo = useCallback(() => {
const tabHistory = historyState[activeTransactionId] || defaultHistory; if (historyIndex <= 0) return;
const entry = tabHistory.entries[tabHistory.index - 1]; const newIndex = historyIndex - 1;
if (!entry || tabHistory.index <= 0) return; const entry = history[newIndex];
dispatchHistory({ type: 'undo', tabId: activeTransactionId }); if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes))); setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges))); setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]); }, [historyIndex, history, setNodes, setEdges]);
const redo = useCallback(() => { const redo = useCallback(() => {
const tabHistory = historyState[activeTransactionId] || defaultHistory; if (historyIndex >= history.length - 1) return;
const entry = tabHistory.entries[tabHistory.index + 1]; const newIndex = historyIndex + 1;
if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return; const entry = history[newIndex];
dispatchHistory({ type: 'redo', tabId: activeTransactionId }); if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes))); setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges))); setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]); }, [historyIndex, history, setNodes, setEdges]);
// Selected nodes // Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set()); const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@@ -236,29 +205,25 @@ export default function App() {
position, position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined }, data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
}; };
const tabId = activeTransactionId; setNodes(prev => {
setTransactionTabs(prev => prev.map(t => { const next = [...prev, newNode];
if (t.id !== tabId) return t; pushHistory(next, edges);
const nextNodes = [...t.nodes, newNode]; return next;
pushHistory(tabId, nextNodes, t.edges); });
return { ...t, nodes: nextNodes };
}));
addRecentComponent(item.id); addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`); addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`); addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]); }, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => { const onConnect = useCallback((params: Connection) => {
const tabId = activeTransactionId; setEdges(prev => {
setTransactionTabs(prev => prev.map(t => { const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
if (t.id !== tabId) return t; pushHistory(nodes, next);
const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges); return next;
pushHistory(tabId, t.nodes, nextEdges); });
return { ...t, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`); addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`); addAuditEntry('EDGE_CREATE', `Connection created`);
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); }, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => { const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev)); setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
@@ -274,38 +239,34 @@ export default function App() {
const deleteSelectedNodes = useCallback(() => { const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return; if (selectedNodeIds.size === 0) return;
const tabId = activeTransactionId; setNodes(prev => {
setTransactionTabs(prev => prev.map(t => { const next = prev.filter(n => !selectedNodeIds.has(n.id));
if (t.id !== tabId) return t; pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id)); return next;
const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)); });
pushHistory(tabId, nextNodes, nextEdges); setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
return { ...t, nodes: nextNodes, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`); addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`); addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set()); setSelectedNodeIds(new Set());
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); }, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => { const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return; if (selectedNodeIds.size === 0) return;
const tabId = activeTransactionId; const selected = nodes.filter(n => selectedNodeIds.has(n.id));
setTransactionTabs(prev => prev.map(t => { const newNodes = selected.map(n => ({
if (t.id !== tabId) return t; ...n,
const selected = t.nodes.filter(n => selectedNodeIds.has(n.id)); id: `node_${nodeIdCounter.current++}`,
const newNodes = selected.map(n => ({ position: { x: n.position.x + 40, y: n.position.y + 40 },
...n, selected: false,
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)`); setNodes(prev => {
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`); const next = [...prev, ...newNodes];
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); pushHistory(next, edges);
return next;
});
addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`);
}, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate // Validate
const runValidation = useCallback(() => { const runValidation = useCallback(() => {
@@ -365,7 +326,7 @@ export default function App() {
const fee = (Math.random() * 0.1).toFixed(4); const fee = (Math.random() * 0.1).toFixed(4);
const results = [ const results = [
`Simulation complete for ${nodes.length} nodes, ${edges.length} edges`, `Simulation complete for ${nodes.length} nodes, ${edges.length} edges`,
`Estimated fees: ${fee}%`, `Estimated fees: $${fee}%`,
`Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`, `Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`,
`Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`, `Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`,
`Routing: ${routingNodes.length} venue(s) evaluated`, `Routing: ${routingNodes.length} venue(s) evaluated`,
@@ -398,14 +359,14 @@ export default function App() {
const id = `tx-${Date.now()}`; const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]); setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id); setActiveTransactionId(id);
dispatchHistory({ type: 'reset', tabId: id }); setHistory([{ nodes: [], edges: [] }]);
setHistoryIndex(0);
addTerminalEntry('info', 'system', 'New transaction tab created'); addTerminalEntry('info', 'system', 'New transaction tab created');
}, [addTerminalEntry]); }, [addTerminalEntry]);
const closeTransactionTab = useCallback((id: string) => { const closeTransactionTab = useCallback((id: string) => {
if (transactionTabs.length <= 1) return; if (transactionTabs.length <= 1) return;
setTransactionTabs(prev => prev.filter(t => t.id !== id)); setTransactionTabs(prev => prev.filter(t => t.id !== id));
dispatchHistory({ type: 'remove', tabId: id });
if (activeTransactionId === id) { if (activeTransactionId === id) {
const remaining = transactionTabs.filter(t => t.id !== id); const remaining = transactionTabs.filter(t => t.id !== id);
setActiveTransactionId(remaining[0].id); setActiveTransactionId(remaining[0].id);

View File

@@ -58,7 +58,7 @@ interface CanvasProps {
onCloseTab: (id: string) => void; onCloseTab: (id: string) => void;
splitView: boolean; splitView: boolean;
onToggleSplitView: () => void; onToggleSplitView: () => void;
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void; pushHistory: (nodes: Node[], edges: Edge[]) => void;
} }
function CanvasInner({ function CanvasInner({
@@ -105,8 +105,8 @@ function CanvasInner({
setZoomLevel(Math.round(zoom * 100)); setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]); }, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); }; const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); }; const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 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 errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;
@@ -321,7 +321,7 @@ function CanvasInner({
<div className="inspector-separator" /> <div className="inspector-separator" />
<div className="inspector-item"> <div className="inspector-item">
<DollarSign size={12} /> <DollarSign size={12} />
<span>Est. fees: {nodes.length > 0 ? '0.02%' : '—'}</span> <span>Est. fees: {nodes.length > 0 ? '$0.02%' : '—'}</span>
</div> </div>
<div className="inspector-item"> <div className="inspector-item">
<Clock size={12} /> <Clock size={12} />

View File

@@ -287,7 +287,7 @@ export default function RightPanel({
</div> </div>
<div className="context-section"> <div className="context-section">
<span className="context-label">Est. Fees</span> <span className="context-label">Est. Fees</span>
<span className="context-value">{ctx.nodeCount > 0 ? '0.02%' : '—'}</span> <span className="context-value">{ctx.nodeCount > 0 ? '$0.02%' : '—'}</span>
</div> </div>
</div> </div>
)} )}