|
|
|
@@ -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 { 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,44 +66,75 @@ export default function App() {
|
|
|
|
}));
|
|
|
|
}));
|
|
|
|
}, [activeTransactionId]);
|
|
|
|
}, [activeTransactionId]);
|
|
|
|
|
|
|
|
|
|
|
|
// Undo/redo
|
|
|
|
// Undo/redo — per-tab history to prevent cross-tab state corruption
|
|
|
|
const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]);
|
|
|
|
type HistoryState = { entries: HistoryEntry[]; index: number };
|
|
|
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
|
|
type PerTabHistoryState = Record<string, HistoryState>;
|
|
|
|
const skipHistoryRef = useRef(false);
|
|
|
|
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[]) => {
|
|
|
|
const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 };
|
|
|
|
if (skipHistoryRef.current) { skipHistoryRef.current = false; return; }
|
|
|
|
|
|
|
|
setHistory(prev => {
|
|
|
|
const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => {
|
|
|
|
const trimmed = prev.slice(0, historyIndex + 1);
|
|
|
|
const tabState = state[action.tabId] || defaultHistory;
|
|
|
|
const entry = { nodes: JSON.parse(JSON.stringify(n)), edges: JSON.parse(JSON.stringify(e)) };
|
|
|
|
switch (action.type) {
|
|
|
|
const next = [...trimmed, entry];
|
|
|
|
case 'push': {
|
|
|
|
if (next.length > 50) next.shift();
|
|
|
|
const trimmed = tabState.entries.slice(0, tabState.index + 1);
|
|
|
|
return next;
|
|
|
|
const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) };
|
|
|
|
});
|
|
|
|
const next = [...trimmed, entry];
|
|
|
|
setHistoryIndex(prev => Math.min(prev + 1, 50));
|
|
|
|
let newIndex = trimmed.length;
|
|
|
|
}, [historyIndex]);
|
|
|
|
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 undo = useCallback(() => {
|
|
|
|
if (historyIndex <= 0) return;
|
|
|
|
const tabHistory = historyState[activeTransactionId] || defaultHistory;
|
|
|
|
const newIndex = historyIndex - 1;
|
|
|
|
const entry = tabHistory.entries[tabHistory.index - 1];
|
|
|
|
const entry = history[newIndex];
|
|
|
|
if (!entry || tabHistory.index <= 0) return;
|
|
|
|
if (!entry) return;
|
|
|
|
dispatchHistory({ type: 'undo', tabId: activeTransactionId });
|
|
|
|
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)));
|
|
|
|
}, [historyIndex, history, setNodes, setEdges]);
|
|
|
|
}, [historyState, activeTransactionId, setNodes, setEdges]);
|
|
|
|
|
|
|
|
|
|
|
|
const redo = useCallback(() => {
|
|
|
|
const redo = useCallback(() => {
|
|
|
|
if (historyIndex >= history.length - 1) return;
|
|
|
|
const tabHistory = historyState[activeTransactionId] || defaultHistory;
|
|
|
|
const newIndex = historyIndex + 1;
|
|
|
|
const entry = tabHistory.entries[tabHistory.index + 1];
|
|
|
|
const entry = history[newIndex];
|
|
|
|
if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return;
|
|
|
|
if (!entry) return;
|
|
|
|
dispatchHistory({ type: 'redo', tabId: activeTransactionId });
|
|
|
|
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)));
|
|
|
|
}, [historyIndex, history, setNodes, setEdges]);
|
|
|
|
}, [historyState, activeTransactionId, setNodes, setEdges]);
|
|
|
|
|
|
|
|
|
|
|
|
// Selected nodes
|
|
|
|
// Selected nodes
|
|
|
|
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
|
|
|
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
|
|
|
@@ -205,25 +236,29 @@ 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 },
|
|
|
|
};
|
|
|
|
};
|
|
|
|
setNodes(prev => {
|
|
|
|
const tabId = activeTransactionId;
|
|
|
|
const next = [...prev, newNode];
|
|
|
|
setTransactionTabs(prev => prev.map(t => {
|
|
|
|
pushHistory(next, edges);
|
|
|
|
if (t.id !== tabId) return t;
|
|
|
|
return next;
|
|
|
|
const nextNodes = [...t.nodes, newNode];
|
|
|
|
});
|
|
|
|
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`);
|
|
|
|
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
|
|
|
|
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
|
|
|
|
|
|
|
|
|
|
|
|
const onConnect = useCallback((params: Connection) => {
|
|
|
|
const onConnect = useCallback((params: Connection) => {
|
|
|
|
setEdges(prev => {
|
|
|
|
const tabId = activeTransactionId;
|
|
|
|
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
|
|
|
|
setTransactionTabs(prev => prev.map(t => {
|
|
|
|
pushHistory(nodes, next);
|
|
|
|
if (t.id !== tabId) return t;
|
|
|
|
return next;
|
|
|
|
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}`);
|
|
|
|
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source} → ${params.target}`);
|
|
|
|
addAuditEntry('EDGE_CREATE', `Connection created`);
|
|
|
|
addAuditEntry('EDGE_CREATE', `Connection created`);
|
|
|
|
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
|
|
|
|
}, [activeTransactionId, 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));
|
|
|
|
@@ -239,34 +274,38 @@ export default function App() {
|
|
|
|
|
|
|
|
|
|
|
|
const deleteSelectedNodes = useCallback(() => {
|
|
|
|
const deleteSelectedNodes = useCallback(() => {
|
|
|
|
if (selectedNodeIds.size === 0) return;
|
|
|
|
if (selectedNodeIds.size === 0) return;
|
|
|
|
setNodes(prev => {
|
|
|
|
const tabId = activeTransactionId;
|
|
|
|
const next = prev.filter(n => !selectedNodeIds.has(n.id));
|
|
|
|
setTransactionTabs(prev => prev.map(t => {
|
|
|
|
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
|
|
|
|
if (t.id !== tabId) return t;
|
|
|
|
return next;
|
|
|
|
const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id));
|
|
|
|
});
|
|
|
|
const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
|
|
|
|
setEdges(prev => prev.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)`);
|
|
|
|
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, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
|
|
|
|
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
|
|
|
|
|
|
|
|
|
|
|
|
const duplicateSelectedNodes = useCallback(() => {
|
|
|
|
const duplicateSelectedNodes = useCallback(() => {
|
|
|
|
if (selectedNodeIds.size === 0) return;
|
|
|
|
if (selectedNodeIds.size === 0) return;
|
|
|
|
const selected = nodes.filter(n => selectedNodeIds.has(n.id));
|
|
|
|
const tabId = activeTransactionId;
|
|
|
|
const newNodes = selected.map(n => ({
|
|
|
|
setTransactionTabs(prev => prev.map(t => {
|
|
|
|
...n,
|
|
|
|
if (t.id !== tabId) return t;
|
|
|
|
id: `node_${nodeIdCounter.current++}`,
|
|
|
|
const selected = t.nodes.filter(n => selectedNodeIds.has(n.id));
|
|
|
|
position: { x: n.position.x + 40, y: n.position.y + 40 },
|
|
|
|
const newNodes = selected.map(n => ({
|
|
|
|
selected: false,
|
|
|
|
...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 => {
|
|
|
|
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
|
|
|
|
const next = [...prev, ...newNodes];
|
|
|
|
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
|
|
|
|
pushHistory(next, edges);
|
|
|
|
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
|
|
|
|
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(() => {
|
|
|
|
@@ -326,7 +365,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`,
|
|
|
|
@@ -359,14 +398,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);
|
|
|
|
setHistory([{ nodes: [], edges: [] }]);
|
|
|
|
dispatchHistory({ type: 'reset', tabId: id });
|
|
|
|
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);
|
|
|
|
|