- 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>
371 lines
18 KiB
TypeScript
371 lines
18 KiB
TypeScript
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';
|
|
}
|