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
3 changed files with 110 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
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';
@@ -66,44 +66,75 @@ export default function App() {
}));
}, [activeTransactionId]);
// Undo/redo
const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]);
const [historyIndex, setHistoryIndex] = useState(0);
const skipHistoryRef = useRef(false);
// 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 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 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(() => {
if (historyIndex <= 0) return;
const newIndex = historyIndex - 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
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)));
}, [historyIndex, history, setNodes, setEdges]);
}, [historyState, activeTransactionId, setNodes, setEdges]);
const redo = useCallback(() => {
if (historyIndex >= history.length - 1) return;
const newIndex = historyIndex + 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
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)));
}, [historyIndex, history, setNodes, setEdges]);
}, [historyState, activeTransactionId, setNodes, setEdges]);
// Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@@ -205,25 +236,29 @@ export default function App() {
position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
};
setNodes(prev => {
const next = [...prev, newNode];
pushHistory(next, edges);
return next;
});
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`);
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => {
setEdges(prev => {
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
pushHistory(nodes, next);
return next;
});
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`);
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
@@ -239,34 +274,38 @@ export default function App() {
const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
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)));
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, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
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,
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 };
}));
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]);
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate
const runValidation = useCallback(() => {
@@ -326,7 +365,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`,
@@ -359,14 +398,14 @@ export default function App() {
const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id);
setHistory([{ nodes: [], edges: [] }]);
setHistoryIndex(0);
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);

View File

@@ -58,7 +58,7 @@ interface CanvasProps {
onCloseTab: (id: string) => void;
splitView: boolean;
onToggleSplitView: () => void;
pushHistory: (nodes: Node[], edges: Edge[]) => void;
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
}
function CanvasInner({
@@ -105,8 +105,8 @@ function CanvasInner({
setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
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;
@@ -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>
)}