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 TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar';
@@ -66,75 +66,44 @@ export default function App() {
}));
}, [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 };
// Undo/redo
const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]);
const [historyIndex, setHistoryIndex] = useState(0);
const skipHistoryRef = useRef(false);
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 pushHistory = useCallback((n: Node[], e: Edge[]) => {
if (skipHistoryRef.current) { skipHistoryRef.current = false; return; }
setHistory(prev => {
const trimmed = prev.slice(0, historyIndex + 1);
const entry = { nodes: JSON.parse(JSON.stringify(n)), edges: JSON.parse(JSON.stringify(e)) };
const next = [...trimmed, entry];
if (next.length > 50) next.shift();
return next;
});
setHistoryIndex(prev => Math.min(prev + 1, 50));
}, [historyIndex]);
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 });
if (historyIndex <= 0) return;
const newIndex = historyIndex - 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]);
}, [historyIndex, history, 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 });
if (historyIndex >= history.length - 1) return;
const newIndex = historyIndex + 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyState, activeTransactionId, setNodes, setEdges]);
}, [historyIndex, history, setNodes, setEdges]);
// Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@@ -236,29 +205,25 @@ export default function App() {
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 };
}));
setNodes(prev => {
const next = [...prev, newNode];
pushHistory(next, edges);
return next;
});
addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
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 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 };
}));
setEdges(prev => {
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
pushHistory(nodes, next);
return next;
});
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`);
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
@@ -274,38 +239,34 @@ export default function App() {
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 };
}));
setNodes(prev => {
const next = prev.filter(n => !selectedNodeIds.has(n.id));
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
return next;
});
setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set());
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
}, [selectedNodeIds, setNodes, setEdges, edges, 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 };
const selected = 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,
}));
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
setNodes(prev => {
const next = [...prev, ...newNodes];
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
const runValidation = useCallback(() => {
@@ -365,7 +326,7 @@ export default function App() {
const fee = (Math.random() * 0.1).toFixed(4);
const results = [
`Simulation complete for ${nodes.length} nodes, ${edges.length} edges`,
`Estimated fees: ${fee}%`,
`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`,
@@ -398,14 +359,14 @@ export default function App() {
const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id);
dispatchHistory({ type: 'reset', tabId: id });
setHistory([{ nodes: [], edges: [] }]);
setHistoryIndex(0);
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);

View File

@@ -58,7 +58,7 @@ interface CanvasProps {
onCloseTab: (id: string) => void;
splitView: boolean;
onToggleSplitView: () => void;
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
pushHistory: (nodes: Node[], edges: Edge[]) => void;
}
function CanvasInner({
@@ -105,8 +105,8 @@ function CanvasInner({
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 handleZoomIn = () => { reactFlowInstance.zoomIn(); 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 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-item">
<DollarSign size={12} />
<span>Est. fees: {nodes.length > 0 ? '0.02%' : '—'}</span>
<span>Est. fees: {nodes.length > 0 ? '$0.02%' : '—'}</span>
</div>
<div className="inspector-item">
<Clock size={12} />

View File

@@ -287,7 +287,7 @@ export default function RightPanel({
</div>
<div className="context-section">
<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>
)}