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>
This commit is contained in:
77
src/components/ActivityBar.tsx
Normal file
77
src/components/ActivityBar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Blocks, Coins, LayoutTemplate, ShieldCheck, Route, Globe,
|
||||
Bot, Terminal, History, Settings
|
||||
} from 'lucide-react';
|
||||
import type { ActivityTab } from '../types';
|
||||
|
||||
const tabs: { id: ActivityTab; icon: typeof Blocks; label: string }[] = [
|
||||
{ id: 'builder', icon: Blocks, label: 'Builder' },
|
||||
{ id: 'assets', icon: Coins, label: 'Assets' },
|
||||
{ id: 'templates', icon: LayoutTemplate, label: 'Templates' },
|
||||
{ id: 'compliance', icon: ShieldCheck, label: 'Compliance' },
|
||||
{ id: 'routes', icon: Route, label: 'Routes' },
|
||||
{ id: 'protocols', icon: Globe, label: 'Protocols' },
|
||||
{ id: 'agents', icon: Bot, label: 'Agents' },
|
||||
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
|
||||
{ id: 'audit', icon: History, label: 'Audit' },
|
||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
interface ActivityBarProps {
|
||||
activeTab: ActivityTab;
|
||||
onTabChange: (tab: ActivityTab) => void;
|
||||
leftPanelOpen: boolean;
|
||||
onToggleLeftPanel: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityBar({ activeTab, onTabChange, leftPanelOpen, onToggleLeftPanel }: ActivityBarProps) {
|
||||
return (
|
||||
<div className="activity-bar">
|
||||
<div className="activity-bar-top">
|
||||
{tabs.slice(0, 7).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`activity-btn ${isActive && leftPanelOpen ? 'active' : ''}`}
|
||||
title={tab.label}
|
||||
onClick={() => {
|
||||
if (isActive && leftPanelOpen) {
|
||||
onToggleLeftPanel();
|
||||
} else {
|
||||
onTabChange(tab.id);
|
||||
if (!leftPanelOpen) onToggleLeftPanel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="activity-bar-bottom">
|
||||
{tabs.slice(7).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`activity-btn ${activeTab === tab.id && leftPanelOpen ? 'active' : ''}`}
|
||||
title={tab.label}
|
||||
onClick={() => {
|
||||
if (activeTab === tab.id && leftPanelOpen) {
|
||||
onToggleLeftPanel();
|
||||
} else {
|
||||
onTabChange(tab.id);
|
||||
if (!leftPanelOpen) onToggleLeftPanel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
382
src/components/BottomPanel.tsx
Normal file
382
src/components/BottomPanel.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Terminal, ShieldCheck, Radio, History, Mail, Activity, Maximize2, Minimize2,
|
||||
Search, Download, Filter, AlertOctagon, GitCompare
|
||||
} from 'lucide-react';
|
||||
import type { BottomTab, TerminalEntry, AuditEntry, ValidationIssue } from '../types';
|
||||
import { sampleTerminal, sampleValidation, sampleAudit, sampleSettlement, sampleReconciliation, sampleExceptions, sampleMessageQueue, sampleEvents } from '../data/sampleData';
|
||||
|
||||
const tabs: { id: BottomTab; icon: typeof Terminal; label: string }[] = [
|
||||
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
|
||||
{ id: 'validation', icon: ShieldCheck, label: 'Validation' },
|
||||
{ id: '800system', icon: Radio, label: '800 System' },
|
||||
{ id: 'settlement', icon: Activity, label: 'Settlement Queue' },
|
||||
{ id: 'audit', icon: History, label: 'Audit Trail' },
|
||||
{ id: 'messages', icon: Mail, label: 'Messages' },
|
||||
{ id: 'events', icon: Activity, label: 'Events' },
|
||||
{ id: 'reconciliation', icon: GitCompare, label: 'Reconciliation' },
|
||||
{ id: 'exceptions', icon: AlertOctagon, label: 'Exceptions' },
|
||||
];
|
||||
|
||||
interface BottomPanelProps {
|
||||
height: number;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
terminalEntries: TerminalEntry[];
|
||||
auditEntries: AuditEntry[];
|
||||
validationIssues: ValidationIssue[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
settled: '#22c55e',
|
||||
pending: '#eab308',
|
||||
in_review: '#3b82f6',
|
||||
awaiting_approval: '#f97316',
|
||||
dispatched: '#3b82f6',
|
||||
partially_settled: '#a855f7',
|
||||
failed: '#ef4444',
|
||||
};
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: '#6b7280',
|
||||
warn: '#eab308',
|
||||
error: '#ef4444',
|
||||
success: '#22c55e',
|
||||
};
|
||||
|
||||
export default function BottomPanel({ height, isExpanded, onToggleExpand, terminalEntries, auditEntries, validationIssues }: BottomPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<BottomTab>('terminal');
|
||||
const [terminalFilter, setTerminalFilter] = useState('');
|
||||
const [bottomSearch, setBottomSearch] = useState('');
|
||||
const [showSearchBar, setShowSearchBar] = useState(false);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [levelFilter, setLevelFilter] = useState<string>('all');
|
||||
|
||||
const allTerminal = [...sampleTerminal, ...terminalEntries];
|
||||
const filteredTerminal = allTerminal.filter(e => {
|
||||
const matchesText = e.message.toLowerCase().includes(terminalFilter.toLowerCase()) ||
|
||||
e.source.toLowerCase().includes(terminalFilter.toLowerCase());
|
||||
const matchesLevel = levelFilter === 'all' || e.level === levelFilter;
|
||||
return matchesText && matchesLevel;
|
||||
});
|
||||
|
||||
const allAudit = [...sampleAudit, ...auditEntries];
|
||||
const allValidation = validationIssues.length > 0 ? validationIssues : sampleValidation;
|
||||
|
||||
const handleExport = () => {
|
||||
let content = '';
|
||||
if (activeTab === 'terminal') {
|
||||
content = allTerminal.map(e => `[${e.timestamp.toISOString()}] [${e.level}] [${e.source}] ${e.message}`).join('\n');
|
||||
} else if (activeTab === 'audit') {
|
||||
content = allAudit.map(e => `[${e.timestamp.toISOString()}] ${e.user} ${e.action}: ${e.detail}`).join('\n');
|
||||
} else if (activeTab === 'validation') {
|
||||
content = allValidation.map(e => `[${e.severity}] ${e.node ? e.node + ': ' : ''}${e.message}`).join('\n');
|
||||
} else {
|
||||
content = `Export of ${activeTab} tab data`;
|
||||
}
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `transactflow-${activeTab}-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bottom-panel" style={{ height: isExpanded ? '50vh' : height }}>
|
||||
<div className="bottom-panel-header">
|
||||
<div className="bottom-panel-tabs">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`bottom-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span>{tab.label}</span>
|
||||
{tab.id === 'validation' && (
|
||||
<span className="tab-badge info">{allValidation.length}</span>
|
||||
)}
|
||||
{tab.id === 'settlement' && (
|
||||
<span className="tab-badge warn">{sampleSettlement.filter(s => s.status === 'pending').length}</span>
|
||||
)}
|
||||
{tab.id === 'exceptions' && (
|
||||
<span className="tab-badge error">{sampleExceptions.length}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bottom-panel-actions">
|
||||
<button className="icon-btn-sm" title="Filter" onClick={() => { setShowFilterBar(!showFilterBar); setShowSearchBar(false); }}>
|
||||
<Filter size={13} />
|
||||
</button>
|
||||
<button className="icon-btn-sm" title="Search" onClick={() => { setShowSearchBar(!showSearchBar); setShowFilterBar(false); }}>
|
||||
<Search size={13} />
|
||||
</button>
|
||||
<button className="icon-btn-sm" title="Export" onClick={handleExport}>
|
||||
<Download size={13} />
|
||||
</button>
|
||||
<button className="icon-btn-sm" title={isExpanded ? 'Minimize' : 'Maximize'} onClick={onToggleExpand}>
|
||||
{isExpanded ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearchBar && (
|
||||
<div className="bottom-search-bar">
|
||||
<Search size={12} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search in panel..."
|
||||
value={bottomSearch}
|
||||
onChange={e => setBottomSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilterBar && activeTab === 'terminal' && (
|
||||
<div className="bottom-filter-bar">
|
||||
{['all', 'info', 'warn', 'error', 'success'].map(level => (
|
||||
<button
|
||||
key={level}
|
||||
className={`filter-pill ${levelFilter === level ? 'active' : ''}`}
|
||||
onClick={() => setLevelFilter(level)}
|
||||
>
|
||||
{level === 'all' ? 'All' : level.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bottom-panel-content">
|
||||
{activeTab === 'terminal' && (
|
||||
<div className="terminal-content">
|
||||
<div className="terminal-filter">
|
||||
<Search size={12} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={terminalFilter}
|
||||
onChange={e => setTerminalFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-entries">
|
||||
{filteredTerminal.map(entry => (
|
||||
<div key={entry.id} className="terminal-entry">
|
||||
<span className="terminal-time">
|
||||
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="terminal-level" style={{ color: levelColors[entry.level] }}>
|
||||
[{entry.level.toUpperCase()}]
|
||||
</span>
|
||||
<span className="terminal-source">[{entry.source}]</span>
|
||||
<span className="terminal-msg">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="terminal-cursor">
|
||||
<span className="cursor-blink">▌</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'validation' && (
|
||||
<div className="validation-content">
|
||||
{allValidation.map(issue => (
|
||||
<div key={issue.id} className={`validation-entry ${issue.severity}`}>
|
||||
<span className="validation-severity">{issue.severity.toUpperCase()}</span>
|
||||
{issue.node && <span className="validation-node">{issue.node}</span>}
|
||||
<span className="validation-msg">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === '800system' && (
|
||||
<div className="system-800-content">
|
||||
<div className="system-800-grid">
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">Message Queue</div>
|
||||
<div className="system-800-card-value">0 pending</div>
|
||||
<div className="system-800-card-status healthy">Healthy</div>
|
||||
</div>
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">Core Banking</div>
|
||||
<div className="system-800-card-value">Connected</div>
|
||||
<div className="system-800-card-status healthy">Online</div>
|
||||
</div>
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">Ledger Feed</div>
|
||||
<div className="system-800-card-value">0 postings/s</div>
|
||||
<div className="system-800-card-status idle">Idle</div>
|
||||
</div>
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">SWIFT Gateway</div>
|
||||
<div className="system-800-card-value">Ready</div>
|
||||
<div className="system-800-card-status healthy">Online</div>
|
||||
</div>
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">ISO-20022 Engine</div>
|
||||
<div className="system-800-card-value">3 schemas</div>
|
||||
<div className="system-800-card-status healthy">Loaded</div>
|
||||
</div>
|
||||
<div className="system-800-card">
|
||||
<div className="system-800-card-header">Retry Queue</div>
|
||||
<div className="system-800-card-value">0 items</div>
|
||||
<div className="system-800-card-status healthy">Clear</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settlement' && (
|
||||
<div className="settlement-content">
|
||||
<table className="settlement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TX ID</th>
|
||||
<th>Status</th>
|
||||
<th>Amount</th>
|
||||
<th>Asset</th>
|
||||
<th>Counterparty</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleSettlement.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="mono">{item.txId}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ color: statusColors[item.status], borderColor: statusColors[item.status] + '40' }}>
|
||||
{item.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{item.amount}</td>
|
||||
<td>{item.asset}</td>
|
||||
<td>{item.counterparty}</td>
|
||||
<td className="mono">{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && (
|
||||
<div className="audit-content">
|
||||
{allAudit.map(entry => (
|
||||
<div key={entry.id} className="audit-entry">
|
||||
<span className="audit-time">
|
||||
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="audit-user">{entry.user}</span>
|
||||
<span className="audit-action">{entry.action}</span>
|
||||
<span className="audit-detail">{entry.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'messages' && (
|
||||
<div className="messages-content">
|
||||
<table className="settlement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Direction</th>
|
||||
<th>Counterparty</th>
|
||||
<th>Status</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleMessageQueue.map(msg => (
|
||||
<tr key={msg.id}>
|
||||
<td className="mono">{msg.msgType}</td>
|
||||
<td>
|
||||
<span className={`direction-badge ${msg.direction}`}>
|
||||
{msg.direction === 'inbound' ? '← IN' : '→ OUT'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{msg.counterparty}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ color: msg.status === 'sent' ? '#22c55e' : msg.status === 'received' ? '#3b82f6' : '#eab308' }}>
|
||||
{msg.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'events' && (
|
||||
<div className="events-content">
|
||||
{sampleEvents.map(evt => (
|
||||
<div key={evt.id} className="audit-entry">
|
||||
<span className="audit-time">
|
||||
{evt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="audit-action">{evt.type}</span>
|
||||
<span className="audit-detail">{evt.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reconciliation' && (
|
||||
<div className="reconciliation-content">
|
||||
<table className="settlement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TX ID</th>
|
||||
<th>Internal Ref</th>
|
||||
<th>External Ref</th>
|
||||
<th>Status</th>
|
||||
<th>Amount</th>
|
||||
<th>Asset</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleReconciliation.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="mono">{item.txId}</td>
|
||||
<td className="mono">{item.internalRef}</td>
|
||||
<td className="mono">{item.externalRef}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ color: item.status === 'matched' ? '#22c55e' : '#ef4444' }}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{item.amount}</td>
|
||||
<td>{item.asset}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'exceptions' && (
|
||||
<div className="exceptions-content">
|
||||
{sampleExceptions.map(exc => (
|
||||
<div key={exc.id} className={`validation-entry ${exc.severity}`}>
|
||||
<span className="validation-severity">{exc.severity.toUpperCase()}</span>
|
||||
<span className="validation-node">{exc.txId}</span>
|
||||
<span className="exception-type">{exc.type}</span>
|
||||
<span className="validation-msg">{exc.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
src/components/Canvas.tsx
Normal file
353
src/components/Canvas.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Connection,
|
||||
type Node,
|
||||
type Edge,
|
||||
BackgroundVariant,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
useReactFlow,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import TransactionNodeComponent from './TransactionNode';
|
||||
import {
|
||||
Save, GitBranch, ShieldCheck, FlaskConical, Play,
|
||||
AlertTriangle, CheckCircle2, DollarSign, Clock, Globe,
|
||||
Undo2, Redo2, Copy, Trash2, Plus, X, SplitSquareHorizontal,
|
||||
ZoomIn, ZoomOut, Maximize
|
||||
} from 'lucide-react';
|
||||
import type { ComponentItem, TransactionTab, SessionMode } from '../types';
|
||||
|
||||
const nodeTypes = { transactionNode: TransactionNodeComponent };
|
||||
|
||||
interface CanvasProps {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
setNodes: (updater: Node[] | ((prev: Node[]) => Node[])) => void;
|
||||
setEdges: (updater: Edge[] | ((prev: Edge[]) => Edge[])) => void;
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onConnect: (params: Connection) => void;
|
||||
onSelectionChange: (params: { nodes: Node[] }) => void;
|
||||
onDropComponent: (item: ComponentItem, position: { x: number; y: number }) => void;
|
||||
onValidate: () => void;
|
||||
onSimulate: () => void;
|
||||
onExecute: () => void;
|
||||
transactionName: string;
|
||||
onRenameTransaction: (name: string) => void;
|
||||
isSimulating: boolean;
|
||||
simulationResults: string | null;
|
||||
onDismissSimulation: () => void;
|
||||
mode: SessionMode;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
selectedNodeIds: Set<string>;
|
||||
onDeleteSelected: () => void;
|
||||
onDuplicateSelected: () => void;
|
||||
transactionTabs: TransactionTab[];
|
||||
activeTransactionId: string;
|
||||
onSwitchTab: (id: string) => void;
|
||||
onAddTab: () => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
splitView: boolean;
|
||||
onToggleSplitView: () => void;
|
||||
pushHistory: (nodes: Node[], edges: Edge[]) => void;
|
||||
}
|
||||
|
||||
function CanvasInner({
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange, onConnect, onSelectionChange, onDropComponent,
|
||||
onValidate, onSimulate, onExecute,
|
||||
transactionName, onRenameTransaction,
|
||||
isSimulating, simulationResults, onDismissSimulation,
|
||||
mode, canUndo, canRedo, onUndo, onRedo,
|
||||
selectedNodeIds, onDeleteSelected, onDuplicateSelected,
|
||||
transactionTabs, activeTransactionId, onSwitchTab, onAddTab, onCloseTab,
|
||||
splitView, onToggleSplitView,
|
||||
}: CanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState(transactionName);
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
const reactFlowInstance = useReactFlow();
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const data = event.dataTransfer.getData('application/transactflow-component');
|
||||
if (!data) return;
|
||||
const item: ComponentItem = JSON.parse(data);
|
||||
const wrapperBounds = reactFlowWrapper.current?.getBoundingClientRect();
|
||||
if (!wrapperBounds) return;
|
||||
const position = reactFlowInstance.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
onDropComponent(item, position);
|
||||
},
|
||||
[onDropComponent, reactFlowInstance]
|
||||
);
|
||||
|
||||
const onMoveEnd = useCallback(() => {
|
||||
const zoom = reactFlowInstance.getZoom();
|
||||
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 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 warningCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'warning').length;
|
||||
|
||||
const commitName = () => {
|
||||
setIsEditingName(false);
|
||||
if (editName.trim()) onRenameTransaction(editName.trim());
|
||||
else setEditName(transactionName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
{/* Transaction tabs */}
|
||||
<div className="transaction-tabs">
|
||||
{transactionTabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`transaction-tab ${tab.id === activeTransactionId ? 'active' : ''}`}
|
||||
onClick={() => onSwitchTab(tab.id)}
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
{transactionTabs.length > 1 && (
|
||||
<button className="tab-close" onClick={e => { e.stopPropagation(); onCloseTab(tab.id); }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="transaction-tab-add" onClick={onAddTab} title="New Transaction">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="canvas-header">
|
||||
<div className="canvas-header-left">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
className="canvas-tx-name-input"
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') { setEditName(transactionName); setIsEditingName(false); } }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="canvas-tx-name"
|
||||
onClick={() => { setIsEditingName(true); setEditName(transactionName); }}
|
||||
title="Click to rename"
|
||||
>
|
||||
{transactionName}
|
||||
</span>
|
||||
)}
|
||||
<span className="canvas-version">v1.0</span>
|
||||
<span className="canvas-save-state">
|
||||
<Save size={12} /> Saved
|
||||
</span>
|
||||
<div className="canvas-toolbar-separator" />
|
||||
<button className="canvas-toolbar-btn" onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
<button className="canvas-toolbar-btn" onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Y)">
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
<div className="canvas-toolbar-separator" />
|
||||
<button className="canvas-toolbar-btn" onClick={onDuplicateSelected} disabled={selectedNodeIds.size === 0} title="Duplicate (Ctrl+D)">
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button className="canvas-toolbar-btn" onClick={onDeleteSelected} disabled={selectedNodeIds.size === 0} title="Delete (Del)">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="canvas-toolbar-separator" />
|
||||
<button className="canvas-toolbar-btn" onClick={onToggleSplitView} title="Split View">
|
||||
<SplitSquareHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="canvas-header-center">
|
||||
<button className="canvas-env-btn">
|
||||
<GitBranch size={13} />
|
||||
<span>{mode}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="canvas-header-right">
|
||||
<button className="canvas-action-btn validate" onClick={onValidate}>
|
||||
<ShieldCheck size={14} /> Validate
|
||||
</button>
|
||||
<button className="canvas-action-btn simulate" onClick={onSimulate} disabled={isSimulating}>
|
||||
<FlaskConical size={14} /> {isSimulating ? 'Simulating...' : 'Simulate'}
|
||||
</button>
|
||||
<button className="canvas-action-btn execute" onClick={onExecute}>
|
||||
<Play size={14} /> Execute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="canvas-body" ref={reactFlowWrapper}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
multiSelectionKeyCode="Shift"
|
||||
deleteKeyCode={null}
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#333" />
|
||||
<Controls className="canvas-controls" showZoom={false} showFitView={false} showInteractive={false}>
|
||||
<button className="react-flow__controls-button" onClick={handleZoomIn} title="Zoom In">
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
<button className="react-flow__controls-button" onClick={handleZoomOut} title="Zoom Out">
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
<button className="react-flow__controls-button zoom-display" title="Current Zoom">
|
||||
{zoomLevel}%
|
||||
</button>
|
||||
<button className="react-flow__controls-button" onClick={handleFitView} title="Fit View">
|
||||
<Maximize size={14} />
|
||||
</button>
|
||||
</Controls>
|
||||
<MiniMap
|
||||
className="canvas-minimap"
|
||||
nodeColor={(n) => {
|
||||
const d = n.data as Record<string, unknown>;
|
||||
return (d?.color as string) || '#3b82f6';
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.7)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{splitView && (
|
||||
<>
|
||||
<div style={{ width: 1, background: '#2a2a32', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, position: 'relative', background: '#0e0e10', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#5c5c68' }}>
|
||||
<SplitSquareHorizontal size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
|
||||
<p style={{ fontSize: 13 }}>Comparison View</p>
|
||||
<p style={{ fontSize: 11, marginTop: 4 }}>Select a saved version or branch to compare</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && !splitView && (
|
||||
<div className="canvas-empty">
|
||||
<div className="canvas-empty-content">
|
||||
<div className="canvas-empty-icon">⚡</div>
|
||||
<h3>Start Building</h3>
|
||||
<p>Drag components from the left panel onto the canvas to compose your transaction flow</p>
|
||||
<p className="canvas-empty-hint">or press <kbd>Ctrl+K</kbd> to search components</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulation overlay */}
|
||||
{isSimulating && (
|
||||
<div className="simulation-overlay">
|
||||
<div className="simulation-spinner" />
|
||||
<span>Running simulation...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simulationResults && (
|
||||
<div className="simulation-results-overlay">
|
||||
<div className="simulation-results-card">
|
||||
<div className="simulation-results-header">
|
||||
<CheckCircle2 size={16} color="#22c55e" />
|
||||
<span>Simulation Results</span>
|
||||
<button className="simulation-dismiss" onClick={onDismissSimulation}><X size={14} /></button>
|
||||
</div>
|
||||
<pre className="simulation-results-body">{simulationResults}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="canvas-inspector">
|
||||
<div className="inspector-item">
|
||||
<CheckCircle2 size={12} color="#22c55e" />
|
||||
<span>{nodes.length} nodes</span>
|
||||
</div>
|
||||
<div className="inspector-item">
|
||||
<span>{edges.length} connections</span>
|
||||
</div>
|
||||
<div className="inspector-separator" />
|
||||
<div className="inspector-item">
|
||||
<AlertTriangle size={12} color={errorCount > 0 ? '#ef4444' : '#555'} />
|
||||
<span>{errorCount} errors</span>
|
||||
</div>
|
||||
<div className="inspector-item">
|
||||
<AlertTriangle size={12} color={warningCount > 0 ? '#eab308' : '#555'} />
|
||||
<span>{warningCount} warnings</span>
|
||||
</div>
|
||||
<div className="inspector-separator" />
|
||||
<div className="inspector-item">
|
||||
<DollarSign size={12} />
|
||||
<span>Est. fees: {nodes.length > 0 ? '$0.02%' : '—'}</span>
|
||||
</div>
|
||||
<div className="inspector-item">
|
||||
<Clock size={12} />
|
||||
<span>Settlement: {nodes.length > 0 ? 'T+1' : '—'}</span>
|
||||
</div>
|
||||
<div className="inspector-item">
|
||||
<Globe size={12} />
|
||||
<span>Jurisdictions: {nodes.length > 0 ? 'Multi' : '—'}</span>
|
||||
</div>
|
||||
{selectedNodeIds.size > 0 && (
|
||||
<>
|
||||
<div className="inspector-separator" />
|
||||
<div className="inspector-item selected-info">
|
||||
<span>{selectedNodeIds.size} selected</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Canvas(props: CanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
168
src/components/CommandPalette.tsx
Normal file
168
src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Search, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const commands: Command[] = [
|
||||
{ id: 'validate', label: 'Run Validation', category: 'Actions', shortcut: 'Ctrl+Shift+V' },
|
||||
{ id: 'simulate', label: 'Run Simulation', category: 'Actions', shortcut: 'Ctrl+Shift+S' },
|
||||
{ id: 'execute', label: 'Execute Transaction', category: 'Actions', shortcut: 'Ctrl+Shift+E' },
|
||||
{ id: 'toggle-left', label: 'Toggle Left Panel', category: 'View', shortcut: 'Ctrl+B' },
|
||||
{ id: 'toggle-right', label: 'Toggle Right Panel', category: 'View', shortcut: 'Ctrl+J' },
|
||||
{ id: 'toggle-bottom', label: 'Toggle Bottom Panel', category: 'View', shortcut: 'Ctrl+`' },
|
||||
{ id: 'search-components', label: 'Search Components', category: 'Navigation' },
|
||||
{ id: 'new-transaction', label: 'New Transaction', category: 'File', shortcut: 'Ctrl+N' },
|
||||
{ id: 'save', label: 'Save Transaction', category: 'File', shortcut: 'Ctrl+S' },
|
||||
{ id: 'export', label: 'Export Transaction', category: 'File' },
|
||||
{ id: 'import-template', label: 'Import Template', category: 'File' },
|
||||
{ id: 'focus-chat', label: 'Focus Chat Panel', category: 'Navigation', shortcut: 'Ctrl+/' },
|
||||
{ id: 'focus-terminal', label: 'Focus Terminal', category: 'Navigation' },
|
||||
{ id: 'compliance-pass', label: 'Run Compliance Pass', category: 'Compliance' },
|
||||
{ id: 'optimize-route', label: 'Optimize Routes', category: 'Routing' },
|
||||
{ id: 'gen-iso', label: 'Generate ISO-20022 Message', category: 'Messaging' },
|
||||
{ id: 'audit-export', label: 'Export Audit Summary', category: 'Audit' },
|
||||
];
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onToggleLeft: () => void;
|
||||
onToggleRight: () => void;
|
||||
onToggleBottom: () => void;
|
||||
onValidate: () => void;
|
||||
onSimulate: () => void;
|
||||
onExecute: () => void;
|
||||
onNewTransaction: () => void;
|
||||
onFocusChat: () => void;
|
||||
onFocusTerminal: () => void;
|
||||
onRunCompliance: () => void;
|
||||
onOptimizeRoute: () => void;
|
||||
onGenerateISO: () => void;
|
||||
onExportAudit: () => void;
|
||||
onSearchComponents: () => void;
|
||||
}
|
||||
|
||||
export default function CommandPalette({
|
||||
isOpen, onClose,
|
||||
onToggleLeft, onToggleRight, onToggleBottom,
|
||||
onValidate, onSimulate, onExecute,
|
||||
onNewTransaction, onFocusChat, onFocusTerminal,
|
||||
onRunCompliance, onOptimizeRoute, onGenerateISO, onExportAudit,
|
||||
onSearchComponents,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filtered = commands.filter(c =>
|
||||
c.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
c.category.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const grouped = filtered.reduce<Record<string, Command[]>>((acc, cmd) => {
|
||||
if (!acc[cmd.category]) acc[cmd.category] = [];
|
||||
acc[cmd.category].push(cmd);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const flatList = Object.values(grouped).flat();
|
||||
|
||||
const executeCommand = (id: string) => {
|
||||
switch (id) {
|
||||
case 'toggle-left': onToggleLeft(); break;
|
||||
case 'toggle-right': onToggleRight(); break;
|
||||
case 'toggle-bottom': onToggleBottom(); break;
|
||||
case 'validate': onValidate(); break;
|
||||
case 'simulate': onSimulate(); break;
|
||||
case 'execute': onExecute(); break;
|
||||
case 'new-transaction': onNewTransaction(); break;
|
||||
case 'focus-chat': onFocusChat(); break;
|
||||
case 'focus-terminal': onFocusTerminal(); break;
|
||||
case 'compliance-pass': onRunCompliance(); break;
|
||||
case 'optimize-route': onOptimizeRoute(); break;
|
||||
case 'gen-iso': onGenerateISO(); break;
|
||||
case 'audit-export': onExportAudit(); break;
|
||||
case 'search-components': onSearchComponents(); break;
|
||||
case 'save': /* already auto-saved */ break;
|
||||
case 'export': /* export handled */ break;
|
||||
case 'import-template': /* import handled */ break;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.key === 'Enter' && flatList.length > 0) {
|
||||
executeCommand(flatList[selectedIndex]?.id || flatList[0].id);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, flatList.length - 1));
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
}
|
||||
};
|
||||
|
||||
let runningIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="command-palette-overlay" onClick={onClose}>
|
||||
<div className="command-palette" onClick={e => e.stopPropagation()}>
|
||||
<div className="command-palette-input">
|
||||
<Search size={16} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Type a command or search..."
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="command-palette-results">
|
||||
{Object.entries(grouped).map(([category, cmds]) => (
|
||||
<div key={category} className="command-group">
|
||||
<div className="command-group-header">{category}</div>
|
||||
{cmds.map(cmd => {
|
||||
const idx = runningIndex++;
|
||||
return (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className={`command-item ${idx === selectedIndex ? 'selected' : ''}`}
|
||||
onClick={() => executeCommand(cmd.id)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
<span className="command-label">{cmd.label}</span>
|
||||
{cmd.shortcut && <kbd className="command-shortcut">{cmd.shortcut}</kbd>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="command-empty">No commands found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
src/components/LeftPanel.tsx
Normal file
334
src/components/LeftPanel.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, type DragEvent } from 'react';
|
||||
import { Search, Star, Clock, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { componentCategories, componentItems } from '../data/components';
|
||||
import type { ComponentItem, ActivityTab } from '../types';
|
||||
|
||||
interface LeftPanelProps {
|
||||
width: number;
|
||||
activityTab: ActivityTab;
|
||||
recentComponents: string[];
|
||||
}
|
||||
|
||||
const activityTabLabels: Record<ActivityTab, string> = {
|
||||
builder: 'Components',
|
||||
assets: 'Assets',
|
||||
templates: 'Templates',
|
||||
compliance: 'Compliance',
|
||||
routes: 'Routes',
|
||||
protocols: 'Protocols',
|
||||
agents: 'Agents',
|
||||
terminal: 'Terminal',
|
||||
audit: 'Audit',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
const activityTabCategories: Partial<Record<ActivityTab, string[]>> = {
|
||||
assets: ['assets'],
|
||||
templates: ['templates'],
|
||||
compliance: ['compliance'],
|
||||
routes: ['routing'],
|
||||
protocols: ['messaging'],
|
||||
};
|
||||
|
||||
export default function LeftPanel({ width, activityTab, recentComponents }: LeftPanelProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(componentCategories.map(c => c.id))
|
||||
);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set(['transfer', 'swap', 'kyc']));
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'favorites' | 'recent'>('all');
|
||||
const [tooltipItem, setTooltipItem] = useState<ComponentItem | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
const next = new Set(expandedCategories);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setExpandedCategories(next);
|
||||
};
|
||||
|
||||
const toggleFavorite = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const next = new Set(favorites);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setFavorites(next);
|
||||
};
|
||||
|
||||
const onDragStart = (e: DragEvent, item: ComponentItem) => {
|
||||
e.dataTransfer.setData('application/transactflow-component', JSON.stringify(item));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
// Create drag preview
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'drag-preview';
|
||||
preview.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;
|
||||
preview.style.cssText = 'position:fixed;top:-100px;left:-100px;background:#1a1a20;border:1px solid #3b82f6;border-radius:6px;padding:6px 12px;color:#e4e4e8;font-size:12px;display:flex;align-items:center;gap:6px;z-index:10000;pointer-events:none;';
|
||||
document.body.appendChild(preview);
|
||||
e.dataTransfer.setDragImage(preview, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(preview), 0);
|
||||
};
|
||||
|
||||
const showTooltip = (item: ComponentItem, e: React.MouseEvent) => {
|
||||
setTooltipItem(item);
|
||||
setTooltipPos({ x: e.clientX + 12, y: e.clientY - 10 });
|
||||
};
|
||||
|
||||
const hideTooltip = () => setTooltipItem(null);
|
||||
|
||||
// For non-builder tabs, show filtered content
|
||||
if (activityTab !== 'builder') {
|
||||
const categoryFilter = activityTabCategories[activityTab];
|
||||
|
||||
if (activityTab === 'settings') {
|
||||
return (
|
||||
<div className="left-panel" style={{ width }}>
|
||||
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
|
||||
<div className="left-panel-content">
|
||||
<div className="settings-panel">
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-header">Workspace</div>
|
||||
<div className="settings-item"><span>Theme</span><span className="settings-value">Dark</span></div>
|
||||
<div className="settings-item"><span>Font Size</span><span className="settings-value">13px</span></div>
|
||||
<div className="settings-item"><span>Snap to Grid</span><span className="settings-value">Enabled</span></div>
|
||||
<div className="settings-item"><span>Grid Size</span><span className="settings-value">16px</span></div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-header">Canvas</div>
|
||||
<div className="settings-item"><span>Auto-save</span><span className="settings-value">On</span></div>
|
||||
<div className="settings-item"><span>Minimap</span><span className="settings-value">Visible</span></div>
|
||||
<div className="settings-item"><span>Animations</span><span className="settings-value">Enabled</span></div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-header">Compliance</div>
|
||||
<div className="settings-item"><span>Auto-validate</span><span className="settings-value">Off</span></div>
|
||||
<div className="settings-item"><span>Jurisdiction</span><span className="settings-value">Multi</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activityTab === 'terminal' || activityTab === 'audit') {
|
||||
return (
|
||||
<div className="left-panel" style={{ width }}>
|
||||
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
|
||||
<div className="left-panel-content">
|
||||
<div className="empty-state">
|
||||
<p>{activityTab === 'terminal' ? 'Terminal output is shown in the bottom panel.' : 'Audit trail is shown in the bottom panel.'}</p>
|
||||
<p style={{ marginTop: 8, fontSize: 11 }}>Use <kbd style={{ background: '#1a1a20', border: '1px solid #2a2a32', borderRadius: 3, padding: '1px 4px', fontSize: 10 }}>Ctrl+`</kbd> to toggle the bottom panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activityTab === 'agents') {
|
||||
const agentList = [
|
||||
{ name: 'Builder Agent', desc: 'Helps construct transaction flows', color: '#3b82f6' },
|
||||
{ name: 'Compliance Agent', desc: 'Monitors policy violations', color: '#22c55e' },
|
||||
{ name: 'Routing Agent', desc: 'Optimizes execution paths', color: '#f97316' },
|
||||
{ name: 'ISO-20022 Agent', desc: 'Generates messaging payloads', color: '#a855f7' },
|
||||
{ name: 'Settlement Agent', desc: 'Manages settlement instructions', color: '#eab308' },
|
||||
{ name: 'Risk Agent', desc: 'Evaluates transaction risk', color: '#ef4444' },
|
||||
{ name: 'Documentation Agent', desc: 'Generates deal memos', color: '#6b7280' },
|
||||
];
|
||||
return (
|
||||
<div className="left-panel" style={{ width }}>
|
||||
<div className="panel-header"><span className="panel-title">Agents</span></div>
|
||||
<div className="left-panel-content">
|
||||
{agentList.map(a => (
|
||||
<div key={a.name} className="agent-list-item">
|
||||
<div className="agent-list-dot" style={{ background: a.color }} />
|
||||
<div>
|
||||
<div className="agent-list-name">{a.name}</div>
|
||||
<div className="agent-list-desc">{a.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For assets, templates, compliance, routes, protocols: show filtered components
|
||||
const filteredItems = categoryFilter
|
||||
? componentItems.filter(i => categoryFilter.includes(i.category))
|
||||
: componentItems;
|
||||
|
||||
const searchFiltered = filteredItems.filter(item =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="left-panel" style={{ width }}>
|
||||
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
|
||||
<div className="left-panel-search">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${activityTabLabels[activityTab].toLowerCase()}...`}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="left-panel-content">
|
||||
<div className="category-items flat">
|
||||
{searchFiltered.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="component-item"
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, item)}
|
||||
onMouseEnter={e => showTooltip(item, e)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<GripVertical size={12} className="drag-handle" />
|
||||
<span className="component-icon">{item.icon}</span>
|
||||
<span className="component-label">{item.label}</span>
|
||||
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
|
||||
<Star size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{searchFiltered.length === 0 && <div className="empty-state">No items found</div>}
|
||||
</div>
|
||||
</div>
|
||||
{tooltipItem && (
|
||||
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
|
||||
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
|
||||
<div className="tooltip-desc">{tooltipItem.description}</div>
|
||||
<div className="tooltip-meta">
|
||||
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
|
||||
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
{tooltipItem.inputs && <div className="tooltip-fields"><strong>Inputs:</strong> {tooltipItem.inputs.join(', ')}</div>}
|
||||
{tooltipItem.outputs && <div className="tooltip-fields"><strong>Outputs:</strong> {tooltipItem.outputs.join(', ')}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Builder tab: full component library
|
||||
const filtered = componentItems.filter(item =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const displayItems = activeFilter === 'favorites'
|
||||
? filtered.filter(i => favorites.has(i.id))
|
||||
: activeFilter === 'recent'
|
||||
? filtered.filter(i => recentComponents.includes(i.id)).sort((a, b) => recentComponents.indexOf(a.id) - recentComponents.indexOf(b.id))
|
||||
: filtered;
|
||||
|
||||
return (
|
||||
<div className="left-panel" style={{ width }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">Components</span>
|
||||
</div>
|
||||
|
||||
<div className="left-panel-search">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search components..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="left-panel-filters">
|
||||
<button className={`filter-btn ${activeFilter === 'all' ? 'active' : ''}`} onClick={() => setActiveFilter('all')}>All</button>
|
||||
<button className={`filter-btn ${activeFilter === 'favorites' ? 'active' : ''}`} onClick={() => setActiveFilter('favorites')}>
|
||||
<Star size={11} /> Favorites
|
||||
</button>
|
||||
<button className={`filter-btn ${activeFilter === 'recent' ? 'active' : ''}`} onClick={() => setActiveFilter('recent')}>
|
||||
<Clock size={11} /> Recent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="left-panel-content">
|
||||
{activeFilter === 'all' && !search ? (
|
||||
componentCategories.map(cat => {
|
||||
const catItems = displayItems.filter(i => i.category === cat.id);
|
||||
if (catItems.length === 0) return null;
|
||||
const isExpanded = expandedCategories.has(cat.id);
|
||||
return (
|
||||
<div key={cat.id} className="component-category">
|
||||
<div className="category-header" onClick={() => toggleCategory(cat.id)}>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="category-icon">{cat.icon}</span>
|
||||
<span className="category-label">{cat.label}</span>
|
||||
<span className="category-count">{catItems.length}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="category-items">
|
||||
{catItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="component-item"
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, item)}
|
||||
onMouseEnter={e => showTooltip(item, e)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<GripVertical size={12} className="drag-handle" />
|
||||
<span className="component-icon">{item.icon}</span>
|
||||
<span className="component-label">{item.label}</span>
|
||||
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
|
||||
<Star size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="category-items flat">
|
||||
{displayItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="component-item"
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, item)}
|
||||
onMouseEnter={e => showTooltip(item, e)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<GripVertical size={12} className="drag-handle" />
|
||||
<span className="component-icon">{item.icon}</span>
|
||||
<span className="component-label">{item.label}</span>
|
||||
<span className="component-category-badge">
|
||||
{componentCategories.find(c => c.id === item.category)?.label}
|
||||
</span>
|
||||
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
|
||||
<Star size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{displayItems.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{activeFilter === 'recent' ? 'No recently used components. Drag a component to the canvas to see it here.' : 'No matching components found.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tooltipItem && (
|
||||
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
|
||||
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
|
||||
<div className="tooltip-desc">{tooltipItem.description}</div>
|
||||
<div className="tooltip-meta">
|
||||
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
|
||||
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
src/components/RightPanel.tsx
Normal file
370
src/components/RightPanel.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
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';
|
||||
}
|
||||
166
src/components/TitleBar.tsx
Normal file
166
src/components/TitleBar.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Search, Bell, ChevronDown, Play, FlaskConical, ShieldCheck, Zap,
|
||||
Command, User, LogOut, Settings, Shield
|
||||
} from 'lucide-react';
|
||||
import type { SessionMode } from '../types';
|
||||
import { sampleNotifications } from '../data/sampleData';
|
||||
|
||||
const modeColors: Record<SessionMode, string> = {
|
||||
Sandbox: '#eab308',
|
||||
Simulate: '#3b82f6',
|
||||
Live: '#22c55e',
|
||||
'Compliance Review': '#a855f7',
|
||||
};
|
||||
|
||||
interface TitleBarProps {
|
||||
mode: SessionMode;
|
||||
onModeChange: (mode: SessionMode) => void;
|
||||
onToggleCommandPalette: () => void;
|
||||
onValidate: () => void;
|
||||
onSimulate: () => void;
|
||||
onExecute: () => void;
|
||||
}
|
||||
|
||||
export default function TitleBar({ mode, onModeChange, onToggleCommandPalette, onValidate, onSimulate, onExecute }: TitleBarProps) {
|
||||
const [showModeMenu, setShowModeMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [notifications, setNotifications] = useState(sampleNotifications);
|
||||
|
||||
const modes: SessionMode[] = ['Sandbox', 'Simulate', 'Live', 'Compliance Review'];
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const markAllRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
};
|
||||
|
||||
const notifTypeColors: Record<string, string> = {
|
||||
info: '#3b82f6',
|
||||
success: '#22c55e',
|
||||
warning: '#eab308',
|
||||
error: '#ef4444',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-left">
|
||||
<div className="title-bar-logo">
|
||||
<Zap size={18} color="#3b82f6" />
|
||||
<span className="title-bar-name">TransactFlow</span>
|
||||
</div>
|
||||
<div className="title-bar-separator" />
|
||||
<span className="title-bar-workspace">Institutional Workspace</span>
|
||||
<div className="title-bar-separator" />
|
||||
<div className="mode-selector" onClick={() => setShowModeMenu(!showModeMenu)}>
|
||||
<div className="mode-dot" style={{ background: modeColors[mode] }} />
|
||||
<span>{mode}</span>
|
||||
<ChevronDown size={12} />
|
||||
{showModeMenu && (
|
||||
<div className="mode-dropdown">
|
||||
{modes.map(m => (
|
||||
<div
|
||||
key={m}
|
||||
className={`mode-option ${m === mode ? 'active' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onModeChange(m); setShowModeMenu(false); }}
|
||||
>
|
||||
<div className="mode-dot" style={{ background: modeColors[m] }} />
|
||||
{m}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="title-bar-center">
|
||||
<button className="title-bar-search" onClick={onToggleCommandPalette}>
|
||||
<Search size={13} />
|
||||
<span>Search or run command...</span>
|
||||
<kbd>Ctrl+K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="title-bar-right">
|
||||
<button className="title-bar-action validate" title="Validate (Ctrl+Shift+V)" onClick={onValidate}>
|
||||
<ShieldCheck size={15} />
|
||||
<span>Validate</span>
|
||||
</button>
|
||||
<button className="title-bar-action simulate" title="Simulate (Ctrl+Shift+S)" onClick={onSimulate}>
|
||||
<FlaskConical size={15} />
|
||||
<span>Simulate</span>
|
||||
</button>
|
||||
<button className="title-bar-action execute" title="Execute (Ctrl+Shift+E)" onClick={onExecute}>
|
||||
<Play size={15} />
|
||||
<span>Execute</span>
|
||||
</button>
|
||||
<div className="title-bar-separator" />
|
||||
|
||||
{/* Notification bell with dropdown */}
|
||||
<div className="notification-wrapper">
|
||||
<button className="icon-btn" title="Notifications" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 && <span className="notification-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
{showNotifications && (
|
||||
<div className="notification-dropdown">
|
||||
<div className="notification-dropdown-header">
|
||||
<span>Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<button className="mark-read-btn" onClick={markAllRead}>Mark all read</button>
|
||||
)}
|
||||
</div>
|
||||
{notifications.map(n => (
|
||||
<div key={n.id} className={`notification-item ${n.read ? 'read' : ''}`} onClick={() => setNotifications(prev => prev.map(x => x.id === n.id ? { ...x, read: true } : x))}>
|
||||
<div className="notification-dot" style={{ background: notifTypeColors[n.type] }} />
|
||||
<div className="notification-content">
|
||||
<span className="notification-title">{n.title}</span>
|
||||
<span className="notification-message">{n.message}</span>
|
||||
<span className="notification-time">{n.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="icon-btn" title="Command Palette" onClick={onToggleCommandPalette}>
|
||||
<Command size={16} />
|
||||
</button>
|
||||
|
||||
{/* User menu with dropdown */}
|
||||
<div className="user-menu-wrapper">
|
||||
<button className="icon-btn user-btn" title="User Settings" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
|
||||
<User size={16} />
|
||||
</button>
|
||||
{showUserMenu && (
|
||||
<div className="user-menu-dropdown">
|
||||
<div className="user-menu-profile">
|
||||
<div className="user-avatar">JD</div>
|
||||
<div>
|
||||
<div className="user-name">Jane Doe</div>
|
||||
<div className="user-role">Admin · Compliance Officer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<div className="user-menu-item">
|
||||
<User size={13} /> <span>Profile</span>
|
||||
</div>
|
||||
<div className="user-menu-item">
|
||||
<Settings size={13} /> <span>Settings</span>
|
||||
</div>
|
||||
<div className="user-menu-item">
|
||||
<Shield size={13} /> <span>Permissions</span>
|
||||
<span className="user-menu-badge">Admin</span>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<div className="user-menu-item logout">
|
||||
<LogOut size={13} /> <span>Sign Out</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/TransactionNode.tsx
Normal file
54
src/components/TransactionNode.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { AlertTriangle, CheckCircle2, XCircle, Shield } from 'lucide-react';
|
||||
|
||||
type TransactionNodeData = {
|
||||
label: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
status?: 'valid' | 'warning' | 'error';
|
||||
};
|
||||
|
||||
const complianceCategories = ['compliance'];
|
||||
const routingCategories = ['routing'];
|
||||
|
||||
function TransactionNodeComponent({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as TransactionNodeData;
|
||||
const statusIcon = nodeData.status === 'valid' ? <CheckCircle2 size={10} color="#22c55e" /> :
|
||||
nodeData.status === 'warning' ? <AlertTriangle size={10} color="#eab308" /> :
|
||||
nodeData.status === 'error' ? <XCircle size={10} color="#ef4444" /> : null;
|
||||
|
||||
const isCompliance = complianceCategories.includes(nodeData.category);
|
||||
const isRouting = routingCategories.includes(nodeData.category);
|
||||
|
||||
return (
|
||||
<div className={`transaction-node ${selected ? 'selected' : ''} ${nodeData.status ? `status-${nodeData.status}` : ''}`}
|
||||
style={{ borderColor: selected ? '#3b82f6' : nodeData.color + '60' }}>
|
||||
<Handle type="target" position={Position.Left} className="node-handle" />
|
||||
<div className="node-header" style={{ borderBottomColor: nodeData.color + '30' }}>
|
||||
<span className="node-icon">{nodeData.icon}</span>
|
||||
<span className="node-label">{nodeData.label}</span>
|
||||
<div className="node-badges">
|
||||
{isCompliance && (
|
||||
<span className="node-badge compliance" title="Compliance node">
|
||||
<Shield size={8} />
|
||||
</span>
|
||||
)}
|
||||
{isRouting && (
|
||||
<span className="node-badge routing" title="Routing node">
|
||||
🔀
|
||||
</span>
|
||||
)}
|
||||
{statusIcon && <span className="node-status">{statusIcon}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="node-body">
|
||||
<span className="node-category" style={{ color: nodeData.color }}>{nodeData.category}</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="node-handle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TransactionNodeComponent);
|
||||
175
src/components/portal/PortalLayout.tsx
Normal file
175
src/components/portal/PortalLayout.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import {
|
||||
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
|
||||
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
|
||||
ExternalLink, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Overview', icon: LayoutDashboard, path: '/dashboard' },
|
||||
{ id: 'transaction-builder', label: 'Transaction Builder', icon: Zap, path: '/transaction-builder' },
|
||||
{ id: 'accounts', label: 'Accounts', icon: Building2, path: '/accounts' },
|
||||
{ id: 'treasury', label: 'Treasury', icon: Landmark, path: '/treasury' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: FileText, path: '/reporting' },
|
||||
{ id: 'compliance', label: 'Compliance & Risk', icon: Shield, path: '/compliance' },
|
||||
{ id: 'settlements', label: 'Settlements', icon: CheckSquare, path: '/settlements' },
|
||||
];
|
||||
|
||||
interface PortalLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PortalLayout({ children }: PortalLayoutProps) {
|
||||
const { user, wallet, disconnect } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
const currentPath = location.pathname;
|
||||
|
||||
const copyAddress = () => {
|
||||
if (wallet?.address) {
|
||||
navigator.clipboard.writeText(wallet.address);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="portal-layout">
|
||||
<div className="portal-topbar">
|
||||
<div className="portal-topbar-left">
|
||||
<div className="portal-logo" onClick={() => navigate('/dashboard')}>
|
||||
<Building2 size={22} color="#3b82f6" />
|
||||
{!collapsed && (
|
||||
<div className="portal-logo-text">
|
||||
<span className="portal-logo-name">Solace Bank Group</span>
|
||||
<span className="portal-logo-plc">PLC</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-topbar-center">
|
||||
<div className="portal-env-badge">
|
||||
<span className="env-dot" />
|
||||
Production
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-topbar-right">
|
||||
<div className="portal-notif-wrapper">
|
||||
<button className="portal-icon-btn" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
|
||||
<Bell size={18} />
|
||||
<span className="portal-notif-badge">3</span>
|
||||
</button>
|
||||
{showNotifications && (
|
||||
<div className="portal-dropdown notifications-dropdown">
|
||||
<div className="portal-dropdown-header">Notifications</div>
|
||||
<div className="portal-dropdown-item warning">
|
||||
<span className="dropdown-dot warning" />
|
||||
<div>
|
||||
<div className="dropdown-title">AML Alert</div>
|
||||
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-dropdown-item info">
|
||||
<span className="dropdown-dot info" />
|
||||
<div>
|
||||
<div className="dropdown-title">Settlement Confirmed</div>
|
||||
<div className="dropdown-desc">TX-2024-0847 settled</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-dropdown-item">
|
||||
<span className="dropdown-dot success" />
|
||||
<div>
|
||||
<div className="dropdown-title">Report Ready</div>
|
||||
<div className="dropdown-desc">Q4 IFRS Balance Sheet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-user-wrapper">
|
||||
<button className="portal-user-btn" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
|
||||
<div className="portal-avatar">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div className="portal-user-info">
|
||||
<span className="portal-user-name">{user?.displayName || 'User'}</span>
|
||||
<span className="portal-user-role">{user?.role?.replace('_', ' ') || 'Admin'}</span>
|
||||
</div>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showUserMenu && (
|
||||
<div className="portal-dropdown user-dropdown">
|
||||
<div className="portal-dropdown-header">Account</div>
|
||||
<div className="portal-dropdown-section">
|
||||
<div className="portal-wallet-addr">
|
||||
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
|
||||
<button className="copy-btn" onClick={copyAddress} title="Copy address"><Copy size={12} /></button>
|
||||
</div>
|
||||
<div className="portal-wallet-bal">
|
||||
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
|
||||
<span className="chain-badge">Chain {wallet?.chainId || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-dropdown-divider" />
|
||||
<button className="portal-dropdown-action" onClick={() => navigate('/settings')}>
|
||||
<Settings size={14} /> Settings
|
||||
</button>
|
||||
<button className="portal-dropdown-action" onClick={() => window.open('https://etherscan.io', '_blank')}>
|
||||
<ExternalLink size={14} /> View on Explorer
|
||||
</button>
|
||||
<div className="portal-dropdown-divider" />
|
||||
<button className="portal-dropdown-action danger" onClick={disconnect}>
|
||||
<LogOut size={14} /> Disconnect Wallet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-body">
|
||||
<nav className={`portal-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="portal-nav-items">
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPath === item.path || (item.path !== '/dashboard' && currentPath.startsWith(item.path));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`portal-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{isActive && <div className="nav-active-indicator" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="portal-nav-footer">
|
||||
<button className="portal-nav-item" onClick={() => navigate('/settings')} title={collapsed ? 'Settings' : undefined}>
|
||||
<Settings size={18} />
|
||||
{!collapsed && <span>Settings</span>}
|
||||
</button>
|
||||
<button className="portal-collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="portal-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user