refactored monorepo for more projects

This commit is contained in:
Boki 2025-06-22 23:48:01 -04:00
parent 4632c174dc
commit 9492f1b15e
180 changed files with 1438 additions and 424 deletions

View file

@ -0,0 +1,20 @@
import { DashboardActivity, DashboardStats, PortfolioTable } from './components';
export function DashboardPage() {
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2">Welcome to Stock Bot Dashboard</h1>
<p className="text-text-secondary mb-6 text-sm">
Monitor your trading performance, manage portfolios, and analyze market data.
</p>
<DashboardStats />
<DashboardActivity />
</div>
<div className="flex-1 min-h-0">
<PortfolioTable />
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Card, CardHeader, CardContent } from '@/components/ui';
export function DashboardActivity() {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<h3 className="text-base font-medium text-text-primary">Recent Activity</h3>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
<span className="text-text-secondary text-sm">No recent activity</span>
<span className="text-text-muted text-xs">--</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-base font-medium text-text-primary">Market Overview</h3>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
<span className="text-text-secondary text-sm">Market data loading...</span>
<span className="text-text-muted text-xs">--</span>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { CurrencyDollarIcon, ChartBarIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
import { StatCard } from '@/components/ui';
export function DashboardStats() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-6">
<StatCard
title="Portfolio Value"
value="$0.00"
subtitle="Total assets"
icon={<CurrencyDollarIcon className="h-5 w-5 text-primary-400" />}
iconBgColor="bg-primary-500/10"
valueColor="text-primary-400"
borderColor="border-primary-500/50"
/>
<StatCard
title="Total Return"
value="+0.00%"
subtitle="Since inception"
icon={<ChartBarIcon className="h-5 w-5 text-success" />}
iconBgColor="bg-success/10"
valueColor="text-success"
borderColor="border-success/50"
/>
<StatCard
title="Active Strategies"
value="0"
subtitle="Running algorithms"
icon={<DocumentTextIcon className="h-5 w-5 text-warning" />}
iconBgColor="bg-warning/10"
valueColor="text-warning"
borderColor="border-warning/50"
/>
</div>
);
}

View file

@ -0,0 +1,642 @@
import { DataTable } from '@/components/ui';
import { ColumnDef } from '@tanstack/react-table';
import React from 'react';
interface PortfolioItem {
symbol: string;
quantity: number;
avgPrice: number;
currentPrice: number;
value: number;
change: number;
changePercent: number;
// Additional columns for stress testing
volume: number;
marketCap: number;
pe: number;
pb: number;
roe: number;
debt: number;
revenue: number;
earnings: number;
dividend: number;
beta: number;
rsi: number;
macd: number;
sma20: number;
sma50: number;
sma200: number;
support: number;
resistance: number;
volatility: number;
sharpe: number;
alpha: number;
correlation: number;
sector: string;
industry: string;
country: string;
exchange: string;
currency: string;
lastUpdate: string;
analyst1: string;
analyst2: string;
analyst3: string;
rating1: number;
rating2: number;
rating3: number;
target1: number;
target2: number;
target3: number;
risk: string;
esg: number;
}
export function PortfolioTable() {
// Generate 100,000 rows of sample data
const data: PortfolioItem[] = React.useMemo(() => {
const symbols = [
'AAPL',
'GOOGL',
'MSFT',
'TSLA',
'AMZN',
'META',
'NFLX',
'NVDA',
'AMD',
'INTC',
'CRM',
'ORCL',
'IBM',
'CSCO',
'UBER',
'LYFT',
'SNAP',
'TWTR',
'SPOT',
'SQ',
];
const sectors = [
'Technology',
'Healthcare',
'Finance',
'Energy',
'Consumer',
'Industrial',
'Materials',
'Utilities',
'Real Estate',
'Telecom',
];
const industries = [
'Software',
'Hardware',
'Biotech',
'Banking',
'Oil & Gas',
'Retail',
'Manufacturing',
'Mining',
'Utilities',
'REITs',
];
const countries = [
'USA',
'Canada',
'UK',
'Germany',
'Japan',
'China',
'India',
'Brazil',
'Australia',
'France',
];
const exchanges = ['NYSE', 'NASDAQ', 'LSE', 'TSX', 'Nikkei', 'SSE', 'BSE', 'ASX', 'Euronext'];
const currencies = ['USD', 'CAD', 'GBP', 'EUR', 'JPY', 'CNY', 'INR', 'BRL', 'AUD'];
const analysts = [
'Goldman Sachs',
'Morgan Stanley',
'JPMorgan',
'Bank of America',
'Wells Fargo',
'Credit Suisse',
'Deutsche Bank',
'Barclays',
'UBS',
'Citigroup',
];
const risks = ['Low', 'Medium', 'High', 'Very High'];
return Array.from({ length: 100000 }, (_, i) => {
const basePrice = 50 + Math.random() * 500;
const change = (Math.random() - 0.5) * 20;
const quantity = Math.floor(Math.random() * 1000) + 1;
return {
symbol: `${symbols[i % symbols.length]}${Math.floor(i / symbols.length)}`,
quantity,
avgPrice: basePrice,
currentPrice: basePrice + change,
value: (basePrice + change) * quantity,
change: change * quantity,
changePercent: (change / basePrice) * 100,
volume: Math.floor(Math.random() * 10000000),
marketCap: Math.floor(Math.random() * 1000000000000),
pe: Math.random() * 50 + 5,
pb: Math.random() * 10 + 0.5,
roe: Math.random() * 30 + 5,
debt: Math.random() * 50,
revenue: Math.floor(Math.random() * 100000000000),
earnings: Math.floor(Math.random() * 10000000000),
dividend: Math.random() * 5,
beta: Math.random() * 3 + 0.5,
rsi: Math.random() * 100,
macd: (Math.random() - 0.5) * 10,
sma20: basePrice + (Math.random() - 0.5) * 10,
sma50: basePrice + (Math.random() - 0.5) * 20,
sma200: basePrice + (Math.random() - 0.5) * 50,
support: basePrice - Math.random() * 20,
resistance: basePrice + Math.random() * 20,
volatility: Math.random() * 100,
sharpe: Math.random() * 3,
alpha: (Math.random() - 0.5) * 20,
correlation: (Math.random() - 0.5) * 2,
sector: sectors[Math.floor(Math.random() * sectors.length)],
industry: industries[Math.floor(Math.random() * industries.length)],
country: countries[Math.floor(Math.random() * countries.length)],
exchange: exchanges[Math.floor(Math.random() * exchanges.length)],
currency: currencies[Math.floor(Math.random() * currencies.length)],
lastUpdate: new Date(Date.now() - Math.random() * 86400000).toISOString(),
analyst1: analysts[Math.floor(Math.random() * analysts.length)],
analyst2: analysts[Math.floor(Math.random() * analysts.length)],
analyst3: analysts[Math.floor(Math.random() * analysts.length)],
rating1: Math.random() * 5 + 1,
rating2: Math.random() * 5 + 1,
rating3: Math.random() * 5 + 1,
target1: basePrice + (Math.random() - 0.3) * 50,
target2: basePrice + (Math.random() - 0.3) * 50,
target3: basePrice + (Math.random() - 0.3) * 50,
risk: risks[Math.floor(Math.random() * risks.length)],
esg: Math.random() * 100,
};
});
}, []);
const columns: ColumnDef<PortfolioItem>[] = [
{
id: 'symbol',
header: 'Symbol',
accessorKey: 'symbol',
size: 120,
cell: ({ getValue }) => (
<span className="font-mono font-bold text-primary-400">{getValue() as string}</span>
),
},
{
id: 'quantity',
header: 'Quantity',
accessorKey: 'quantity',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toLocaleString()}</span>
),
},
{
id: 'avgPrice',
header: 'Avg Price',
accessorKey: 'avgPrice',
size: 120,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'currentPrice',
header: 'Current Price',
accessorKey: 'currentPrice',
size: 120,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'value',
header: 'Value',
accessorKey: 'value',
size: 120,
cell: ({ getValue }) => (
<span className="font-mono font-bold">${(getValue() as number).toLocaleString()}</span>
),
},
{
id: 'change',
header: 'P&L',
accessorKey: 'change',
size: 120,
cell: ({ getValue }) => {
const value = getValue() as number;
const isPositive = value >= 0;
return (
<span className={`font-mono font-medium ${isPositive ? 'text-success' : 'text-danger'}`}>
{isPositive ? '+' : ''}${value.toLocaleString()}
</span>
);
},
},
{
id: 'changePercent',
header: 'P&L %',
accessorKey: 'changePercent',
size: 100,
cell: ({ getValue }) => {
const value = getValue() as number;
const isPositive = value >= 0;
return (
<span className={`font-mono font-medium ${isPositive ? 'text-success' : 'text-danger'}`}>
{isPositive ? '+' : ''}
{value.toFixed(2)}%
</span>
);
},
},
{
id: 'volume',
header: 'Volume',
accessorKey: 'volume',
size: 120,
cell: ({ getValue }) => (
<span className="font-mono text-text-secondary">
{(getValue() as number).toLocaleString()}
</span>
),
},
{
id: 'marketCap',
header: 'Market Cap',
accessorKey: 'marketCap',
size: 120,
cell: ({ getValue }) => {
const value = getValue() as number;
if (value >= 1e12) {return <span className="font-mono">${(value / 1e12).toFixed(2)}T</span>;}
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
return <span className="font-mono">${value.toLocaleString()}</span>;
},
},
{
id: 'pe',
header: 'P/E',
accessorKey: 'pe',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'pb',
header: 'P/B',
accessorKey: 'pb',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'roe',
header: 'ROE %',
accessorKey: 'roe',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
),
},
{
id: 'debt',
header: 'Debt %',
accessorKey: 'debt',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
),
},
{
id: 'revenue',
header: 'Revenue',
accessorKey: 'revenue',
size: 120,
cell: ({ getValue }) => {
const value = getValue() as number;
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
return <span className="font-mono">${value.toLocaleString()}</span>;
},
},
{
id: 'earnings',
header: 'Earnings',
accessorKey: 'earnings',
size: 120,
cell: ({ getValue }) => {
const value = getValue() as number;
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
return <span className="font-mono">${value.toLocaleString()}</span>;
},
},
{
id: 'dividend',
header: 'Dividend %',
accessorKey: 'dividend',
size: 100,
cell: ({ getValue }) => {
const value = getValue() as number;
return <span className="font-mono text-success">{value.toFixed(2)}%</span>;
},
},
{
id: 'beta',
header: 'Beta',
accessorKey: 'beta',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value > 1 ? 'text-warning' : value < 1 ? 'text-success' : 'text-text-primary';
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
},
},
{
id: 'rsi',
header: 'RSI',
accessorKey: 'rsi',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color =
value > 70 ? 'text-danger' : value < 30 ? 'text-success' : 'text-text-primary';
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
},
},
{
id: 'macd',
header: 'MACD',
accessorKey: 'macd',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value > 0 ? 'text-success' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
},
},
{
id: 'sma20',
header: 'SMA 20',
accessorKey: 'sma20',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'sma50',
header: 'SMA 50',
accessorKey: 'sma50',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'sma200',
header: 'SMA 200',
accessorKey: 'sma200',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'support',
header: 'Support',
accessorKey: 'support',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono text-success">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'resistance',
header: 'Resistance',
accessorKey: 'resistance',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono text-danger">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'volatility',
header: 'Volatility',
accessorKey: 'volatility',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
),
},
{
id: 'sharpe',
header: 'Sharpe',
accessorKey: 'sharpe',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'alpha',
header: 'Alpha',
accessorKey: 'alpha',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value > 0 ? 'text-success' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
},
},
{
id: 'correlation',
header: 'Correlation',
accessorKey: 'correlation',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'sector',
header: 'Sector',
accessorKey: 'sector',
size: 150,
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
},
{
id: 'industry',
header: 'Industry',
accessorKey: 'industry',
size: 150,
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
},
{
id: 'country',
header: 'Country',
accessorKey: 'country',
size: 100,
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
},
{
id: 'exchange',
header: 'Exchange',
accessorKey: 'exchange',
size: 100,
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
},
{
id: 'currency',
header: 'Currency',
accessorKey: 'currency',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono text-text-secondary">{getValue() as string}</span>
),
},
{
id: 'lastUpdate',
header: 'Last Update',
accessorKey: 'lastUpdate',
size: 150,
cell: ({ getValue }) => (
<span className="text-xs text-text-muted">
{new Date(getValue() as string).toLocaleString()}
</span>
),
},
{
id: 'analyst1',
header: 'Analyst 1',
accessorKey: 'analyst1',
size: 120,
cell: ({ getValue }) => (
<span className="text-xs text-text-secondary">{getValue() as string}</span>
),
},
{
id: 'analyst2',
header: 'Analyst 2',
accessorKey: 'analyst2',
size: 120,
cell: ({ getValue }) => (
<span className="text-xs text-text-secondary">{getValue() as string}</span>
),
},
{
id: 'analyst3',
header: 'Analyst 3',
accessorKey: 'analyst3',
size: 120,
cell: ({ getValue }) => (
<span className="text-xs text-text-secondary">{getValue() as string}</span>
),
},
{
id: 'rating1',
header: 'Rating 1',
accessorKey: 'rating1',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
},
},
{
id: 'rating2',
header: 'Rating 2',
accessorKey: 'rating2',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
},
},
{
id: 'rating3',
header: 'Rating 3',
accessorKey: 'rating3',
size: 80,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
},
},
{
id: 'target1',
header: 'Target 1',
accessorKey: 'target1',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'target2',
header: 'Target 2',
accessorKey: 'target2',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'target3',
header: 'Target 3',
accessorKey: 'target3',
size: 100,
cell: ({ getValue }) => (
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
),
},
{
id: 'risk',
header: 'Risk Level',
accessorKey: 'risk',
size: 100,
cell: ({ getValue }) => {
const value = getValue() as string;
const color =
value === 'Low' ? 'text-success' : value === 'Medium' ? 'text-warning' : 'text-danger';
return <span className={`px-2 py-1 rounded text-xs font-medium ${color}`}>{value}</span>;
},
},
{
id: 'esg',
header: 'ESG Score',
accessorKey: 'esg',
size: 100,
cell: ({ getValue }) => {
const value = getValue() as number;
const color = value >= 70 ? 'text-success' : value >= 40 ? 'text-warning' : 'text-danger';
return <span className={`font-mono ${color}`}>{value.toFixed(0)}</span>;
},
},
];
return (
<DataTable
data={data}
columns={columns}
onRowClick={_row => {/* Handle row click */}}
className="border border-border rounded-lg"
/>
);
}

View file

@ -0,0 +1,3 @@
export { DashboardStats } from './DashboardStats';
export { DashboardActivity } from './DashboardActivity';
export { PortfolioTable } from './PortfolioTable';

View file

@ -0,0 +1 @@
export { DashboardPage } from './DashboardPage';

View file

@ -0,0 +1,147 @@
import {
ArrowPathIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XMarkIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { ExchangesTable } from './components/ExchangesTable';
import { AddExchangeDialog } from './components/AddExchangeDialog';
import { useExchanges } from './hooks/useExchanges';
export function ExchangesPage() {
const { createExchange } = useExchanges();
const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
type: 'success' | 'error' | null;
message: string;
}>({ type: null, message: '' });
const [showAddDialog, setShowAddDialog] = useState(false);
// Auto-dismiss success messages after 5 seconds
useEffect(() => {
if (syncStatus.type === 'success') {
const timer = setTimeout(() => {
setSyncStatus({ type: null, message: '' });
}, 5000);
return () => clearTimeout(timer);
}
}, [syncStatus.type]);
const handleSync = async () => {
setSyncing(true);
setSyncStatus({ type: null, message: '' });
try {
// TODO: Implement sync functionality
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate sync
setSyncStatus({
type: 'success',
message: 'Exchange sync functionality coming soon!',
});
} catch (error) {
setSyncStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Sync failed with unknown error',
});
} finally {
setSyncing(false);
}
};
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex items-center justify-between flex-shrink-0">
<div>
<h1 className="text-lg font-bold text-text-primary mb-2">Exchange Management</h1>
<p className="text-text-secondary text-sm">
Configure and manage master exchanges with their data sources and providers.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowAddDialog(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<PlusIcon className="h-4 w-4" />
Add Exchange
</button>
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ArrowPathIcon className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Exchanges'}
</button>
</div>
</div>
{syncing && (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4 flex-shrink-0">
<div className="flex items-center gap-2">
<ArrowPathIcon className="h-4 w-4 text-primary-500 animate-spin" />
<span className="text-primary-700 text-sm">
Syncing exchanges from Interactive Brokers data...
</span>
</div>
</div>
)}
{syncStatus.type && (
<div className="fixed bottom-4 right-4 z-50 max-w-sm">
<div className="bg-surface-secondary rounded-lg border border-border p-4 shadow-xl transform transition-all duration-300 ease-out">
<div className="flex items-start gap-3">
{syncStatus.type === 'success' ? (
<CheckCircleIcon className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
) : (
<ExclamationTriangleIcon className="h-5 w-5 text-danger flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium ${
syncStatus.type === 'success' ? 'text-success' : 'text-danger'
}`}
>
{syncStatus.type === 'success' ? 'Sync Completed' : 'Sync Failed'}
</p>
<p className="text-xs mt-1 text-text-secondary">{syncStatus.message}</p>
</div>
<button
onClick={() => setSyncStatus({ type: null, message: '' })}
className="flex-shrink-0 p-1 rounded-full text-text-muted hover:text-text-primary hover:bg-surface transition-colors"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ExchangesTable />
</div>
<AddExchangeDialog
isOpen={showAddDialog}
onClose={() => setShowAddDialog(false)}
onCreateExchange={async (exchangeRequest) => {
try {
await createExchange(exchangeRequest);
setShowAddDialog(false);
setSyncStatus({
type: 'success',
message: `Exchange "${exchangeRequest.code}" created successfully!`,
});
} catch (error) {
setSyncStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to create exchange',
});
}
}}
/>
</div>
);
}

View file

@ -0,0 +1,174 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
import { useCallback } from 'react';
import { CreateExchangeRequest, AddExchangeDialogProps } from '../types';
import { validateExchangeForm } from '../utils/validation';
import { useFormValidation } from '../hooks/useFormValidation';
const initialFormData: CreateExchangeRequest = {
code: '',
name: '',
country: '',
currency: '',
active: true,
};
export function AddExchangeDialog({
isOpen,
onClose,
onCreateExchange,
}: AddExchangeDialogProps) {
const {
formData,
errors,
isSubmitting,
updateField,
handleSubmit,
reset,
} = useFormValidation(initialFormData, validateExchangeForm);
const onSubmit = useCallback(
async (data: CreateExchangeRequest) => {
await onCreateExchange({
...data,
code: data.code.toUpperCase(),
country: data.country.toUpperCase(),
currency: data.currency.toUpperCase(),
});
},
[onCreateExchange]
);
const handleFormSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
handleSubmit(onSubmit, onClose);
},
[handleSubmit, onSubmit, onClose]
);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add New Exchange</DialogTitle>
<p className="text-sm text-text-muted">
Create a new master exchange with no provider mappings
</p>
</DialogHeader>
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* Exchange Code */}
<div>
<label htmlFor="code" className="block text-sm font-medium text-text-primary mb-1">
Exchange Code *
</label>
<input
id="code"
type="text"
value={formData.code}
onChange={e => updateField('code', e.target.value)}
placeholder="e.g., NASDAQ, NYSE, TSX"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.code ? 'border-danger' : 'border-border'
}`}
maxLength={10}
required
/>
{errors.code && <p className="text-xs text-danger mt-1">{errors.code}</p>}
</div>
{/* Exchange Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-primary mb-1">
Exchange Name *
</label>
<input
id="name"
type="text"
value={formData.name}
onChange={e => updateField('name', e.target.value)}
placeholder="e.g., NASDAQ Stock Market, New York Stock Exchange"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
errors.name ? 'border-danger' : 'border-border'
}`}
maxLength={255}
required
/>
{errors.name && <p className="text-xs text-danger mt-1">{errors.name}</p>}
</div>
{/* Country */}
<div>
<label htmlFor="country" className="block text-sm font-medium text-text-primary mb-1">
Country Code *
</label>
<input
id="country"
type="text"
value={formData.country}
onChange={e => updateField('country', e.target.value)}
placeholder="e.g., US, CA, GB"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.country ? 'border-danger' : 'border-border'
}`}
maxLength={2}
required
/>
{errors.country && <p className="text-xs text-danger mt-1">{errors.country}</p>}
</div>
{/* Currency */}
<div>
<label htmlFor="currency" className="block text-sm font-medium text-text-primary mb-1">
Currency Code *
</label>
<input
id="currency"
type="text"
value={formData.currency}
onChange={e => updateField('currency', e.target.value)}
placeholder="e.g., USD, EUR, CAD"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.currency ? 'border-danger' : 'border-border'
}`}
maxLength={3}
required
/>
{errors.currency && <p className="text-xs text-danger mt-1">{errors.currency}</p>}
</div>
{/* Active Toggle */}
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.active}
onChange={e => updateField('active', e.target.checked)}
className="rounded"
/>
<span className="text-sm text-text-primary">Active exchange</span>
</label>
<p className="text-xs text-text-muted mt-1">
Inactive exchanges won't be used for new symbol mappings
</p>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Exchange'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,251 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
import { useCallback, useEffect, useState } from 'react';
import { useExchanges } from '../hooks/useExchanges';
import { CreateProviderMappingRequest } from '../types';
interface AddProviderMappingDialogProps {
isOpen: boolean;
exchangeId: string;
exchangeName: string;
onClose: () => void;
onCreateMapping: (request: CreateProviderMappingRequest) => Promise<any>;
}
export function AddProviderMappingDialog({
isOpen,
exchangeId,
exchangeName,
onClose,
onCreateMapping,
}: AddProviderMappingDialogProps) {
const { fetchProviders, fetchUnmappedProviderExchanges } = useExchanges();
const [providers, setProviders] = useState<string[]>([]);
const [selectedProvider, setSelectedProvider] = useState('');
const [unmappedExchanges, setUnmappedExchanges] = useState<any[]>([]);
const [selectedProviderExchange, setSelectedProviderExchange] = useState('');
const [loading, setLoading] = useState(false);
const [providersLoading, setProvidersLoading] = useState(false);
const [exchangesLoading, setExchangesLoading] = useState(false);
// Load providers on mount
useEffect(() => {
if (isOpen) {
loadProviders();
}
}, [isOpen, loadProviders]);
// Load unmapped exchanges when provider changes
useEffect(() => {
if (selectedProvider) {
loadUnmappedExchanges(selectedProvider);
} else {
setUnmappedExchanges([]);
setSelectedProviderExchange('');
}
}, [selectedProvider, loadUnmappedExchanges]);
const loadProviders = useCallback(async () => {
setProvidersLoading(true);
try {
const providersData = await fetchProviders();
setProviders(providersData);
} catch {
// Error loading providers - could add toast notification here
} finally {
setProvidersLoading(false);
}
}, [fetchProviders]);
const loadUnmappedExchanges = useCallback(
async (provider: string) => {
setExchangesLoading(true);
try {
const exchangesData = await fetchUnmappedProviderExchanges(provider);
setUnmappedExchanges(exchangesData);
} catch {
// Error loading unmapped exchanges - could add toast notification here
} finally {
setExchangesLoading(false);
}
},
[fetchUnmappedProviderExchanges]
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProvider || !selectedProviderExchange) {
return;
}
const selectedExchange = unmappedExchanges.find(
exchange => exchange.provider_exchange_code === selectedProviderExchange
);
if (!selectedExchange) {
return;
}
setLoading(true);
try {
const request: CreateProviderMappingRequest = {
provider: selectedProvider,
provider_exchange_code: selectedExchange.provider_exchange_code,
provider_exchange_name: selectedExchange.provider_exchange_name,
master_exchange_id: exchangeId,
country_code: selectedExchange.country_code,
currency: selectedExchange.currency,
confidence: 1.0,
active: false,
verified: false,
};
await onCreateMapping(request);
} catch {
// Error creating provider mapping - could add toast notification here
} finally {
setLoading(false);
}
},
[selectedProvider, selectedProviderExchange, unmappedExchanges, exchangeId, onCreateMapping]
);
const handleClose = useCallback(() => {
setSelectedProvider('');
setSelectedProviderExchange('');
setUnmappedExchanges([]);
onClose();
}, [onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Provider Mapping</DialogTitle>
<p className="text-sm text-text-muted">
Map a provider exchange to <strong>{exchangeName}</strong>
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Provider Selection */}
<div>
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">
Provider
</label>
<select
id="provider"
value={selectedProvider}
onChange={e => setSelectedProvider(e.target.value)}
disabled={providersLoading}
className="w-full px-3 py-2 border border-border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
>
<option value="">
{providersLoading ? 'Loading providers...' : 'Select a provider'}
</option>
{providers.map(provider => (
<option key={provider} value={provider}>
{provider.toUpperCase()}
</option>
))}
</select>
</div>
{/* Provider Exchange Selection */}
<div>
<label
htmlFor="providerExchange"
className="block text-sm font-medium text-text-primary mb-1"
>
Provider Exchange
</label>
<select
id="providerExchange"
value={selectedProviderExchange}
onChange={e => setSelectedProviderExchange(e.target.value)}
disabled={!selectedProvider || exchangesLoading}
className="w-full px-3 py-2 border border-border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
>
<option value="">
{!selectedProvider
? 'Select a provider first'
: exchangesLoading
? 'Loading exchanges...'
: unmappedExchanges.length === 0
? 'No unmapped exchanges available'
: 'Select an exchange'}
</option>
{unmappedExchanges.map(exchange => (
<option key={exchange.provider_exchange_code} value={exchange.provider_exchange_code}>
{exchange.provider_exchange_code} - {exchange.provider_exchange_name}
{exchange.country_code && ` (${exchange.country_code})`}
</option>
))}
</select>
{selectedProvider && unmappedExchanges.length === 0 && !exchangesLoading && (
<p className="text-xs text-text-muted mt-1">
All exchanges for this provider are already mapped.
</p>
)}
</div>
{/* Selected Exchange Info */}
{selectedProviderExchange && (
<div className="p-3 bg-surface-secondary rounded-md">
<h4 className="text-sm font-medium text-text-primary mb-2">Selected Exchange Info</h4>
{(() => {
const exchange = unmappedExchanges.find(
ex => ex.provider_exchange_code === selectedProviderExchange
);
if (!exchange) {return null;}
return (
<div className="space-y-1 text-xs">
<div>
<span className="text-text-muted">Code:</span>{' '}
<span className="font-mono">{exchange.provider_exchange_code}</span>
</div>
<div>
<span className="text-text-muted">Name:</span> {exchange.provider_exchange_name}
</div>
{exchange.country_code && (
<div>
<span className="text-text-muted">Country:</span> {exchange.country_code}
</div>
)}
{exchange.currency && (
<div>
<span className="text-text-muted">Currency:</span> {exchange.currency}
</div>
)}
{exchange.symbol_count && (
<div>
<span className="text-text-muted">Symbols:</span> {exchange.symbol_count}
</div>
)}
</div>
);
})()}
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button
type="submit"
disabled={!selectedProvider || !selectedProviderExchange || loading}
>
{loading ? 'Creating...' : 'Create Mapping'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,216 @@
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
import { AddSourceRequest } from '../types';
interface AddSourceDialogProps {
isOpen: boolean;
onClose: () => void;
onAddSource: (request: AddSourceRequest) => Promise<void>;
exchangeId: string;
exchangeName: string;
}
export function AddSourceDialog({
isOpen,
onClose,
onAddSource,
exchangeName,
}: AddSourceDialogProps) {
const [source, setSource] = useState('');
const [sourceCode, setSourceCode] = useState('');
const [id, setId] = useState('');
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [aliases, setAliases] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!source || !sourceCode || !id || !name || !code) {return;}
setLoading(true);
try {
await onAddSource({
source,
source_code: sourceCode,
mapping: {
id,
name,
code,
aliases: aliases
.split(',')
.map(a => a.trim())
.filter(Boolean),
},
});
// Reset form
setSource('');
setSourceCode('');
setId('');
setName('');
setCode('');
setAliases('');
} catch (_error) {
// TODO: Implement proper error handling/toast notification
// eslint-disable-next-line no-console
console.error('Error adding source:', _error);
} finally {
setLoading(false);
}
};
return (
<Transition appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-lg bg-background border border-border p-6 text-left align-middle shadow-xl transition-all">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-medium text-text-primary">
Add Source to {exchangeName}
</Dialog.Title>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Provider
</label>
<select
value={source}
onChange={e => setSource(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
required
>
<option value="">Select a source</option>
<option value="ib">Interactive Brokers</option>
<option value="alpaca">Alpaca</option>
<option value="polygon">Polygon</option>
<option value="yahoo">Yahoo Finance</option>
<option value="alpha_vantage">Alpha Vantage</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Code
</label>
<input
type="text"
value={sourceCode}
onChange={e => setSourceCode(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., IB, ALP, POLY"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source ID
</label>
<input
type="text"
value={id}
onChange={e => setId(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE, NASDAQ"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Name
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., New York Stock Exchange"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Code
</label>
<input
type="text"
value={code}
onChange={e => setCode(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Aliases (comma-separated)
</label>
<input
type="text"
value={aliases}
onChange={e => setAliases(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE, New York, Big Board"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !source || !sourceCode || !id || !name || !code}
className="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Adding...' : 'Add Source'}
</button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,110 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
import { useCallback, useState } from 'react';
import { TrashIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
interface DeleteExchangeDialogProps {
isOpen: boolean;
exchangeId: string;
exchangeName: string;
providerMappingCount: number;
onClose: () => void;
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
}
export function DeleteExchangeDialog({
isOpen,
exchangeId,
exchangeName,
providerMappingCount,
onClose,
onConfirmDelete,
}: DeleteExchangeDialogProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = useCallback(async () => {
setLoading(true);
try {
const success = await onConfirmDelete(exchangeId);
if (success) {
onClose();
}
} catch {
// Error deleting exchange - could add toast notification here
} finally {
setLoading(false);
}
}, [exchangeId, onConfirmDelete, onClose]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-danger">
<ExclamationTriangleIcon className="h-5 w-5" />
Delete Exchange
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="p-4 bg-danger/10 border border-danger/20 rounded-lg">
<p className="text-sm text-text-primary">
Are you sure you want to delete <strong>"{exchangeName}"</strong>?
</p>
</div>
<div className="space-y-2 text-sm text-text-secondary">
<p className="font-medium text-text-primary">This action will:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Hide the exchange from all lists</li>
{providerMappingCount > 0 && (
<li className="text-yellow-400">
Delete {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}
</li>
)}
<li>Make provider exchanges available for remapping</li>
</ul>
</div>
{providerMappingCount > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-md">
<div className="flex items-start gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-yellow-400">Warning</p>
<p className="text-text-secondary">
This exchange has {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}.
Deleting will permanently remove these mappings and make the provider exchanges
available for mapping to other exchanges.
</p>
</div>
</div>
</div>
)}
<div className="bg-surface-secondary p-3 rounded-md">
<p className="text-xs text-text-muted">
<strong>Note:</strong> This action cannot be undone. The exchange will be hidden
but can be restored by directly updating the database if needed.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={loading}
className="bg-danger hover:bg-danger/90 text-white"
>
<TrashIcon className="h-4 w-4 mr-1" />
{loading ? 'Deleting...' : 'Delete Exchange'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,448 @@
import { DataTable } from '@/components/ui';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react';
import { useExchanges } from '../hooks/useExchanges';
import { Exchange, EditingCell, AddProviderMappingDialogState, DeleteDialogState } from '../types';
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
import { DeleteExchangeDialog } from './DeleteExchangeDialog';
import { sortProviderMappings, getProviderMappingColor, formatProviderMapping, formatDate } from '../utils/formatters';
export function ExchangesTable() {
const {
exchanges,
loading,
error,
updateExchange,
updateProviderMapping,
createProviderMapping,
refetch
} = useExchanges();
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [editValue, setEditValue] = useState('');
const [addProviderDialog, setAddProviderDialog] = useState<AddProviderMappingDialogState | null>(null);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const handleCellEdit = useCallback(
async (id: string, field: string, value: string) => {
if (field === 'name' || field === 'code') {
await updateExchange(id, { [field]: value });
}
setEditingCell(null);
setEditValue('');
},
[updateExchange]
);
const handleToggleActive = useCallback(
async (id: string, currentStatus: boolean) => {
await updateExchange(id, { active: !currentStatus });
},
[updateExchange]
);
const handleAddProviderMapping = useCallback(async (exchangeId: string, exchangeName: string) => {
setAddProviderDialog({ exchangeId, exchangeName });
}, []);
const handleDeleteExchange = useCallback((exchangeId: string, exchangeName: string, providerMappingCount: number) => {
setDeleteDialog({ exchangeId, exchangeName, providerMappingCount });
}, []);
const handleConfirmDelete = useCallback(async (exchangeId: string) => {
const success = await updateExchange(exchangeId, { visible: false });
if (success) {
refetch();
}
return success;
}, [updateExchange, refetch]);
const handleToggleProviderMapping = useCallback(
async (mappingId: string, currentStatus: boolean) => {
const success = await updateProviderMapping(mappingId, { active: !currentStatus });
if (success) {
// Refresh the main table data to get updated counts and mappings
refetch();
}
},
[updateProviderMapping, refetch]
);
const handleRowExpand = useCallback(
async (_row: any) => {
// Row expansion is now handled automatically by TanStack Table
// No need to fetch data since all mappings are already loaded
},
[]
);
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
return [
{
id: 'expander',
header: '',
cell: ({ row }) => {
return row.getCanExpand() ? (
<button
onClick={() => {
row.getToggleExpandedHandler()();
handleRowExpand(row);
}}
className="text-text-secondary hover:text-text-primary transition-colors w-6 h-6 flex items-center justify-center"
>
{row.getIsExpanded() ? '▼' : '▶'}
</button>
) : null;
},
size: 40,
enableResizing: false,
disableTooltip: true,
},
{
id: 'code',
header: 'Code',
accessorKey: 'code',
size: 120,
cell: ({ getValue, row, cell: _cell }) => {
const isEditing =
editingCell?.id === row.original.id && editingCell?.field === 'code';
if (isEditing) {
return (
<input
type="text"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleCellEdit(row.original.id, 'code', editValue)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCellEdit(row.original.id, 'code', editValue);
} else if (e.key === 'Escape') {
setEditingCell(null);
setEditValue('');
}
}}
className="w-full bg-surface border border-border rounded px-2 py-1 text-sm font-mono"
autoFocus
/>
);
}
return (
<div
className="cursor-pointer hover:bg-surface-secondary rounded px-2 py-1 transition-colors text-sm font-mono font-medium truncate overflow-hidden text-ellipsis whitespace-nowrap"
onClick={() => {
setEditingCell({ id: row.original.id, field: 'code' });
setEditValue(getValue() as string);
}}
>
{getValue() as string}
</div>
);
},
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
size: 250,
cell: ({ getValue, row, cell: _cell }) => {
const isEditing =
editingCell?.id === row.original.id && editingCell?.field === 'name';
if (isEditing) {
return (
<input
type="text"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleCellEdit(row.original.id, 'name', editValue)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCellEdit(row.original.id, 'name', editValue);
} else if (e.key === 'Escape') {
setEditingCell(null);
setEditValue('');
}
}}
className="w-full bg-surface border border-border rounded px-2 py-1 text-sm"
autoFocus
/>
);
}
return (
<div
className="cursor-pointer hover:bg-surface-secondary rounded px-2 py-1 transition-colors text-sm truncate overflow-hidden text-ellipsis whitespace-nowrap"
onClick={() => {
setEditingCell({ id: row.original.id, field: 'name' });
setEditValue(getValue() as string);
}}
>
{getValue() as string}
</div>
);
},
},
{
id: 'country',
header: 'Country',
accessorKey: 'country',
size: 80,
cell: ({ getValue }) => (
<span className="text-text-secondary text-sm block truncate">{getValue() as string}</span>
),
},
{
id: 'currency',
header: 'Currency',
accessorKey: 'currency',
size: 80,
cell: ({ getValue }) => (
<span className="font-mono text-text-secondary text-sm block truncate">
{getValue() as string}
</span>
),
},
{
id: 'active',
header: 'Active',
accessorKey: 'active',
size: 80,
disableTooltip: true,
cell: ({ getValue, row }) => {
const isActive = getValue() as boolean;
return (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={() => handleToggleActive(row.original.id, isActive)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-surface-secondary peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500"></div>
</label>
);
},
},
{
id: 'provider_mappings',
header: 'Provider Mappings',
accessorKey: 'provider_mapping_count',
size: 180,
disableTooltip: true,
cell: ({ getValue, row }) => {
const totalMappings = parseInt(getValue() as string) || 0;
const activeMappings = parseInt(row.original.active_mapping_count) || 0;
const _verifiedMappings = parseInt(row.original.verified_mapping_count) || 0;
// Get provider mappings directly from the exchange data
const mappings = row.original.provider_mappings || [];
const sortedMappings = sortProviderMappings(mappings);
return (
<div className="flex flex-col gap-1">
<div className="text-sm">
<span className="text-text-primary font-medium">{totalMappings}</span>
<span className="text-text-muted"> total</span>
{activeMappings > 0 && (
<span className="ml-2 text-green-400 text-xs">
{activeMappings} active
</span>
)}
</div>
{mappings.length > 0 ? (
<div className="flex flex-wrap gap-1 text-xs">
{sortedMappings.slice(0, 3).map((mapping, index) => (
<span key={index} className={getProviderMappingColor(mapping)}>
{formatProviderMapping(mapping)}
</span>
))}
{sortedMappings.length > 3 && (
<span className="text-text-muted">+{sortedMappings.length - 3} more</span>
)}
</div>
) : (
<div className="text-xs text-text-muted">No mappings</div>
)}
</div>
);
},
},
{
id: 'actions',
header: 'Actions',
size: 160,
disableTooltip: true,
cell: ({ row }) => (
<div className="flex gap-1">
<button
onClick={() => handleAddProviderMapping(row.original.id, row.original.name)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors whitespace-nowrap"
title="Add Provider Mapping"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
<button
onClick={() => handleDeleteExchange(
row.original.id,
row.original.name,
parseInt(row.original.provider_mapping_count) || 0
)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-danger/20 text-danger rounded text-xs hover:bg-danger/30 transition-colors whitespace-nowrap"
title="Delete Exchange"
>
<TrashIcon className="h-3 w-3" />
Delete
</button>
</div>
),
},
{
id: 'updated_at',
header: 'Last Updated',
accessorKey: 'updated_at',
size: 120,
cell: ({ getValue }) => (
<span className="text-xs text-text-muted">
{formatDate(getValue() as string)}
</span>
),
},
];
}, [
editingCell,
editValue,
handleCellEdit,
handleToggleActive,
handleAddProviderMapping,
handleDeleteExchange,
handleConfirmDelete,
handleRowExpand,
]);
if (error) {
return (
<div className="bg-danger/10 border border-danger/20 rounded-lg p-4">
<h3 className="text-danger font-medium mb-2">Error Loading Exchanges</h3>
<p className="text-text-secondary text-sm">{error}</p>
<p className="text-text-muted text-xs mt-2">
Make sure the web-api service is running on localhost:4000
</p>
</div>
);
}
const renderSubComponent = ({ row }: { row: any }) => {
const exchange = row.original as Exchange;
const mappings = exchange.provider_mappings || [];
if (mappings.length === 0) {
return (
<div className="p-4 text-center text-text-muted">
<div className="text-sm">No provider mappings found for this exchange.</div>
<button
onClick={() => handleAddProviderMapping(exchange.id, exchange.name)}
className="mt-2 text-primary-400 hover:text-primary-300 text-sm underline"
>
Add the first provider mapping
</button>
</div>
);
}
return (
<div className="p-4 bg-surface-secondary/50">
<h4 className="text-sm font-medium text-text-primary mb-3">Provider Mappings</h4>
<div className="space-y-2">
{mappings.map((mapping) => (
<div key={mapping.id} className="flex items-center justify-between p-3 bg-surface rounded border border-border">
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="font-mono text-xs bg-primary-500/20 text-primary-400 px-2 py-1 rounded">
{mapping.provider.toUpperCase()}
</span>
<span className="font-medium text-text-primary">
{mapping.provider_exchange_code}
</span>
<span className="text-text-secondary">
{mapping.provider_exchange_name}
</span>
{mapping.country_code && (
<span className="text-xs text-text-muted">
{mapping.country_code}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
<span>Confidence: {mapping.confidence}</span>
<span>Created: {formatDate(mapping.created_at)}</span>
{mapping.auto_mapped && <span className="text-yellow-400">Auto-mapped</span>}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{mapping.verified && (
<span className="text-blue-400" title="Verified">
</span>
)}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={mapping.active}
onChange={() => handleToggleProviderMapping(mapping.id, mapping.active)}
className="sr-only peer"
/>
<div className="w-6 h-3 bg-surface-secondary peer-focus:outline-none peer-focus:ring-1 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[1px] after:left-[1px] after:bg-white after:rounded-full after:h-2.5 after:w-2.5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
</div>
</div>
))}
</div>
</div>
);
};
return (
<>
<DataTable
data={exchanges || []}
columns={columns}
loading={loading}
getRowCanExpand={() => true}
renderSubComponent={renderSubComponent}
/>
{addProviderDialog && (
<AddProviderMappingDialog
isOpen={true}
exchangeId={addProviderDialog.exchangeId}
exchangeName={addProviderDialog.exchangeName}
onClose={() => setAddProviderDialog(null)}
onCreateMapping={async (mappingRequest) => {
const result = await createProviderMapping(mappingRequest);
if (result) {
setAddProviderDialog(null);
refetch();
}
}}
/>
)}
{deleteDialog && (
<DeleteExchangeDialog
isOpen={true}
exchangeId={deleteDialog.exchangeId}
exchangeName={deleteDialog.exchangeName}
providerMappingCount={deleteDialog.providerMappingCount}
onClose={() => setDeleteDialog(null)}
onConfirmDelete={handleConfirmDelete}
/>
)}
</>
);
}

View file

@ -0,0 +1,5 @@
export { AddSourceDialog } from './AddSourceDialog';
export { AddProviderMappingDialog } from './AddProviderMappingDialog';
export { AddExchangeDialog } from './AddExchangeDialog';
export { DeleteExchangeDialog } from './DeleteExchangeDialog';
export { ExchangesTable } from './ExchangesTable';

View file

@ -0,0 +1 @@
export { useExchanges } from './useExchanges';

View file

@ -0,0 +1,167 @@
import { useCallback, useEffect, useState } from 'react';
import { exchangeApi } from '../services/exchangeApi';
import {
CreateExchangeRequest,
CreateProviderMappingRequest,
Exchange,
ExchangeDetails,
ExchangeStats,
ProviderExchange,
ProviderMapping,
UpdateExchangeRequest,
UpdateProviderMappingRequest,
} from '../types';
export function useExchanges() {
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchExchanges = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await exchangeApi.getExchanges();
setExchanges(data);
} catch (err) {
// Error fetching exchanges - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
setExchanges([]);
} finally {
setLoading(false);
}
}, []);
const updateExchange = useCallback(
async (id: string, updates: UpdateExchangeRequest): Promise<boolean> => {
try {
await exchangeApi.updateExchange(id, updates);
await fetchExchanges(); // Refresh the list
return true;
} catch (err) {
// Error updating exchange - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to update exchange');
return false;
}
},
[fetchExchanges]
);
const createExchange = useCallback(
async (request: CreateExchangeRequest): Promise<Exchange> => {
try {
const exchange = await exchangeApi.createExchange(request);
await fetchExchanges(); // Refresh the list
return exchange;
} catch (err) {
// Error creating exchange - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to create exchange');
throw err;
}
},
[fetchExchanges]
);
const fetchExchangeDetails = useCallback(async (id: string): Promise<ExchangeDetails | null> => {
try {
return await exchangeApi.getExchangeById(id);
} catch (err) {
// Error fetching exchange details - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch exchange details');
return null;
}
}, []);
const fetchStats = useCallback(async (): Promise<ExchangeStats | null> => {
try {
return await exchangeApi.getExchangeStats();
} catch (err) {
// Error fetching stats - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
return null;
}
}, []);
const fetchProviderMappings = useCallback(
async (provider?: string): Promise<ProviderMapping[]> => {
try {
return await exchangeApi.getProviderMappings(provider);
} catch (err) {
// Error fetching provider mappings - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch provider mappings');
return [];
}
},
[]
);
const updateProviderMapping = useCallback(
async (id: string, updates: UpdateProviderMappingRequest): Promise<boolean> => {
try {
await exchangeApi.updateProviderMapping(id, updates);
return true;
} catch (err) {
// Error updating provider mapping - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to update provider mapping');
return false;
}
},
[]
);
const createProviderMapping = useCallback(
async (request: CreateProviderMappingRequest): Promise<ProviderMapping | null> => {
try {
return await exchangeApi.createProviderMapping(request);
} catch (err) {
// Error creating provider mapping - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to create provider mapping');
return null;
}
},
[]
);
const fetchProviders = useCallback(async (): Promise<string[]> => {
try {
return await exchangeApi.getProviders();
} catch (err) {
// Error fetching providers - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
return [];
}
}, []);
const fetchUnmappedProviderExchanges = useCallback(
async (provider: string): Promise<ProviderExchange[]> => {
try {
return await exchangeApi.getUnmappedProviderExchanges(provider);
} catch (err) {
// Error fetching unmapped exchanges - error state will show in UI
setError(err instanceof Error ? err.message : 'Failed to fetch unmapped exchanges');
return [];
}
},
[]
);
useEffect(() => {
fetchExchanges();
}, [fetchExchanges]);
return {
exchanges,
loading,
error,
refetch: fetchExchanges,
updateExchange,
createExchange,
fetchExchangeDetails,
fetchStats,
fetchProviderMappings,
updateProviderMapping,
createProviderMapping,
fetchProviders,
fetchUnmappedProviderExchanges,
};
}

View file

@ -0,0 +1,67 @@
import { useCallback, useState } from 'react';
import { FormErrors } from '../types';
export function useFormValidation<T>(initialData: T, validateFn: (data: T) => FormErrors) {
const [formData, setFormData] = useState<T>(initialData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback(
(field: keyof T, value: T[keyof T]) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field as string]) {
setErrors(prev => ({ ...prev, [field as string]: '' }));
}
},
[errors]
);
const validate = useCallback((): boolean => {
const newErrors = validateFn(formData);
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData, validateFn]);
const reset = useCallback(() => {
setFormData(initialData);
setErrors({});
setIsSubmitting(false);
}, [initialData]);
const handleSubmit = useCallback(
async (
onSubmit: (data: T) => Promise<void>,
onSuccess?: () => void,
onError?: (error: unknown) => void
) => {
if (!validate()) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
reset();
onSuccess?.();
} catch (error) {
onError?.(error);
} finally {
setIsSubmitting(false);
}
},
[formData, validate, reset]
);
return {
formData,
errors,
isSubmitting,
updateField,
validate,
reset,
handleSubmit,
setIsSubmitting,
};
}

View file

@ -0,0 +1,4 @@
export * from './components';
export { ExchangesPage } from './ExchangesPage';
export * from './hooks';
export * from './types';

View file

@ -0,0 +1,132 @@
import {
ApiResponse,
CreateExchangeRequest,
CreateProviderMappingRequest,
Exchange,
ExchangeDetails,
ExchangeStats,
ProviderExchange,
ProviderMapping,
UpdateExchangeRequest,
UpdateProviderMappingRequest,
} from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
class ExchangeApiService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'API request failed');
}
return data;
}
// Exchanges
async getExchanges(): Promise<Exchange[]> {
const response = await this.request<Exchange[]>('/exchanges');
return response.data || [];
}
async getExchangeById(id: string): Promise<ExchangeDetails | null> {
const response = await this.request<ExchangeDetails>(`/exchanges/${id}`);
return response.data || null;
}
async createExchange(data: CreateExchangeRequest): Promise<Exchange> {
const response = await this.request<Exchange>('/exchanges', {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.data) {
throw new Error('No exchange data returned');
}
return response.data;
}
async updateExchange(id: string, data: UpdateExchangeRequest): Promise<Exchange> {
const response = await this.request<Exchange>(`/exchanges/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
if (!response.data) {
throw new Error('No exchange data returned');
}
return response.data;
}
// Provider Mappings
async getProviderMappings(provider?: string): Promise<ProviderMapping[]> {
const endpoint = provider
? `/exchanges/provider-mappings/${provider}`
: '/exchanges/provider-mappings/all';
const response = await this.request<ProviderMapping[]>(endpoint);
return response.data || [];
}
async createProviderMapping(data: CreateProviderMappingRequest): Promise<ProviderMapping> {
const response = await this.request<ProviderMapping>('/exchanges/provider-mappings', {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.data) {
throw new Error('No provider mapping data returned');
}
return response.data;
}
async updateProviderMapping(
id: string,
data: UpdateProviderMappingRequest
): Promise<ProviderMapping> {
const response = await this.request<ProviderMapping>(`/exchanges/provider-mappings/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
if (!response.data) {
throw new Error('No provider mapping data returned');
}
return response.data;
}
// Providers and Utilities
async getProviders(): Promise<string[]> {
const response = await this.request<string[]>('/exchanges/providers/list');
return response.data || [];
}
async getUnmappedProviderExchanges(provider: string): Promise<ProviderExchange[]> {
const response = await this.request<ProviderExchange[]>(
`/exchanges/provider-exchanges/unmapped/${provider}`
);
return response.data || [];
}
async getExchangeStats(): Promise<ExchangeStats> {
const response = await this.request<ExchangeStats>('/exchanges/stats/summary');
if (!response.data) {
throw new Error('No exchange stats data returned');
}
return response.data;
}
}
// Export singleton instance
export const exchangeApi = new ExchangeApiService();

View file

@ -0,0 +1,69 @@
// API Response types
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
total?: number;
}
// Base entity types
export interface BaseEntity {
id: string;
created_at: string;
updated_at: string;
}
export interface ProviderMapping extends BaseEntity {
provider: string;
provider_exchange_code: string;
provider_exchange_name: string;
master_exchange_id: string;
country_code: string | null;
currency: string | null;
confidence: number;
active: boolean;
verified: boolean;
auto_mapped: boolean;
master_exchange_code?: string;
master_exchange_name?: string;
master_exchange_active?: boolean;
}
export interface Exchange extends BaseEntity {
code: string;
name: string;
country: string;
currency: string;
active: boolean;
visible: boolean;
provider_mapping_count: string;
active_mapping_count: string;
verified_mapping_count: string;
providers: string | null;
provider_mappings: ProviderMapping[];
}
export interface ExchangeDetails {
exchange: Exchange;
provider_mappings: ProviderMapping[];
}
export interface ProviderExchange {
provider_exchange_code: string;
provider_exchange_name: string;
country_code: string | null;
currency: string | null;
symbol_count: number | null;
}
export interface ExchangeStats {
total_exchanges: string;
active_exchanges: string;
countries: string;
currencies: string;
total_provider_mappings: string;
active_provider_mappings: string;
verified_provider_mappings: string;
providers: string;
}

View file

@ -0,0 +1,45 @@
// Component-specific types
export interface EditingCell {
id: string;
field: string;
}
export interface AddProviderMappingDialogState {
exchangeId: string;
exchangeName: string;
}
export interface DeleteDialogState {
exchangeId: string;
exchangeName: string;
providerMappingCount: number;
}
export interface FormErrors {
[key: string]: string;
}
// Dialog props interfaces
export interface BaseDialogProps {
isOpen: boolean;
onClose: () => void;
}
export interface AddExchangeDialogProps extends BaseDialogProps {
onCreateExchange: (request: import('./request.types').CreateExchangeRequest) => Promise<void>;
}
export interface AddProviderMappingDialogProps extends BaseDialogProps {
exchangeId: string;
exchangeName: string;
onCreateMapping: (
request: import('./request.types').CreateProviderMappingRequest
) => Promise<unknown>;
}
export interface DeleteExchangeDialogProps extends BaseDialogProps {
exchangeId: string;
exchangeName: string;
providerMappingCount: number;
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
}

View file

@ -0,0 +1,7 @@
// Re-export all types from organized files
export * from './api.types';
export * from './request.types';
export * from './component.types';
// Legacy compatibility - can be removed later
export type ExchangesApiResponse<T = unknown> = import('./api.types').ApiResponse<T>;

View file

@ -0,0 +1,35 @@
// Request types for API calls
export interface CreateExchangeRequest {
code: string;
name: string;
country: string;
currency: string;
active?: boolean;
}
export interface UpdateExchangeRequest {
name?: string;
active?: boolean;
visible?: boolean;
country?: string;
currency?: string;
}
export interface CreateProviderMappingRequest {
provider: string;
provider_exchange_code: string;
provider_exchange_name?: string;
master_exchange_id: string;
country_code?: string;
currency?: string;
confidence?: number;
active?: boolean;
verified?: boolean;
}
export interface UpdateProviderMappingRequest {
active?: boolean;
verified?: boolean;
confidence?: number;
master_exchange_id?: string;
}

View file

@ -0,0 +1,35 @@
import { ProviderMapping } from '../types';
export function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString();
}
export function formatProviderMapping(mapping: ProviderMapping): string {
return `${mapping.provider.toLowerCase()}(${mapping.provider_exchange_code})`;
}
export function getProviderMappingColor(mapping: ProviderMapping): string {
return mapping.active ? 'text-green-500' : 'text-text-muted';
}
export function sortProviderMappings(mappings: ProviderMapping[]): ProviderMapping[] {
return [...mappings].sort((a, b) => {
// Active mappings first
if (a.active && !b.active) {
return -1;
}
if (!a.active && b.active) {
return 1;
}
// Then by provider name
return a.provider.localeCompare(b.provider);
});
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength) + '...';
}

View file

@ -0,0 +1,38 @@
import { FormErrors } from '../types';
export function validateExchangeForm(data: {
code: string;
name: string;
country: string;
currency: string;
}): FormErrors {
const errors: FormErrors = {};
if (!data.code.trim()) {
errors.code = 'Exchange code is required';
} else if (data.code.length > 10) {
errors.code = 'Exchange code must be 10 characters or less';
}
if (!data.name.trim()) {
errors.name = 'Exchange name is required';
}
if (!data.country.trim()) {
errors.country = 'Country is required';
} else if (data.country.length !== 2) {
errors.country = 'Country must be exactly 2 characters (e.g., US, CA, GB)';
}
if (!data.currency.trim()) {
errors.currency = 'Currency is required';
} else if (data.currency.length !== 3) {
errors.currency = 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)';
}
return errors;
}
export function hasValidationErrors(errors: FormErrors): boolean {
return Object.keys(errors).length > 0;
}