added new exchanges system
This commit is contained in:
parent
95eda4a842
commit
263e9513b7
98 changed files with 4643 additions and 1496 deletions
20
apps/web-app/src/features/dashboard/DashboardPage.tsx
Normal file
20
apps/web-app/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 => console.log('Clicked:', row.symbol)}
|
||||
className="border border-border rounded-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
3
apps/web-app/src/features/dashboard/components/index.ts
Normal file
3
apps/web-app/src/features/dashboard/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DashboardStats } from './DashboardStats';
|
||||
export { DashboardActivity } from './DashboardActivity';
|
||||
export { PortfolioTable } from './PortfolioTable';
|
||||
1
apps/web-app/src/features/dashboard/index.ts
Normal file
1
apps/web-app/src/features/dashboard/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DashboardPage } from './DashboardPage';
|
||||
121
apps/web-app/src/features/exchanges/ExchangesPage.tsx
Normal file
121
apps/web-app/src/features/exchanges/ExchangesPage.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExchangesTable } from './components/ExchangesTable';
|
||||
import { useExchanges } from './hooks/useExchanges';
|
||||
|
||||
export function ExchangesPage() {
|
||||
const { syncExchanges } = useExchanges();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
type: 'success' | 'error' | null;
|
||||
message: string;
|
||||
}>({ type: null, message: '' });
|
||||
|
||||
// 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 {
|
||||
const result = await syncExchanges();
|
||||
if (result) {
|
||||
setSyncStatus({
|
||||
type: 'success',
|
||||
message: `Exchange sync completed successfully! Job ID: ${result.jobId || 'Unknown'}`,
|
||||
});
|
||||
} else {
|
||||
setSyncStatus({
|
||||
type: 'error',
|
||||
message: 'Sync failed - no result returned from server',
|
||||
});
|
||||
}
|
||||
} 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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
// Load unmapped exchanges when provider changes
|
||||
useEffect(() => {
|
||||
if (selectedProvider) {
|
||||
loadUnmappedExchanges(selectedProvider);
|
||||
} else {
|
||||
setUnmappedExchanges([]);
|
||||
setSelectedProviderExchange('');
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
setProvidersLoading(true);
|
||||
try {
|
||||
const providersData = await fetchProviders();
|
||||
setProviders(providersData);
|
||||
} catch (error) {
|
||||
console.error('Error loading providers:', error);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
}, [fetchProviders]);
|
||||
|
||||
const loadUnmappedExchanges = useCallback(
|
||||
async (provider: string) => {
|
||||
setExchangesLoading(true);
|
||||
try {
|
||||
const exchangesData = await fetchUnmappedProviderExchanges(provider);
|
||||
setUnmappedExchanges(exchangesData);
|
||||
} catch (error) {
|
||||
console.error('Error loading unmapped exchanges:', error);
|
||||
} 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) {
|
||||
console.error('Error creating provider mapping:', error);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
import { DataTable } from '@/components/ui';
|
||||
import { PlusIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useExchanges } from '../hooks/useExchanges';
|
||||
import { Exchange, ProviderMapping } from '../types';
|
||||
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
||||
|
||||
export function ExchangesTable() {
|
||||
const {
|
||||
exchanges,
|
||||
loading,
|
||||
error,
|
||||
updateExchange,
|
||||
fetchExchangeDetails,
|
||||
fetchProviderMappings,
|
||||
updateProviderMapping,
|
||||
createProviderMapping,
|
||||
refetch
|
||||
} = useExchanges();
|
||||
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [addProviderDialog, setAddProviderDialog] = useState<{
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
} | null>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [expandedRowData, setExpandedRowData] = useState<Record<string, ProviderMapping[]>>({});
|
||||
|
||||
const handleCellEdit = useCallback(
|
||||
async (id: string, field: string, value: string) => {
|
||||
if (field === 'name') {
|
||||
await updateExchange(id, { name: 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 handleToggleProviderMapping = useCallback(
|
||||
async (mappingId: string, currentStatus: boolean) => {
|
||||
const success = await updateProviderMapping(mappingId, { active: !currentStatus });
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[updateProviderMapping, refetch]
|
||||
);
|
||||
|
||||
const handleToggleExpandRow = useCallback(async (rowId: string) => {
|
||||
setExpandedRows(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) {
|
||||
next.delete(rowId);
|
||||
} else {
|
||||
next.add(rowId);
|
||||
// Load provider mappings for this exchange
|
||||
if (!expandedRowData[rowId]) {
|
||||
fetchExchangeDetails(rowId).then(details => {
|
||||
if (details) {
|
||||
setExpandedRowData(prev => ({
|
||||
...prev,
|
||||
[rowId]: details.provider_mappings
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [fetchExchangeDetails, expandedRowData]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'expand',
|
||||
header: '',
|
||||
size: 30,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => {
|
||||
const isExpanded = expandedRows.has(row.original.id);
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleToggleExpandRow(row.original.id)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
size: 50,
|
||||
enableResizing: false,
|
||||
cell: ({ getValue, cell }) => (
|
||||
<span
|
||||
className="font-mono text-primary-400 text-xs"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
header: 'Code',
|
||||
accessorKey: 'code',
|
||||
size: 80,
|
||||
enableResizing: false,
|
||||
cell: ({ getValue, cell }) => (
|
||||
<span
|
||||
className="font-mono text-text-primary text-sm font-medium"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
size: 200,
|
||||
maxSize: 300,
|
||||
enableResizing: true,
|
||||
cell: ({ getValue, row, cell }) => {
|
||||
const isEditing =
|
||||
editingCell?.id === row.original.id && editingCell?.field === 'name';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
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"
|
||||
onClick={() => {
|
||||
setEditingCell({ id: row.original.id, field: 'name' });
|
||||
setEditValue(getValue() as string);
|
||||
}}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
header: 'Country',
|
||||
accessorKey: 'country',
|
||||
size: 80,
|
||||
maxSize: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-text-secondary text-sm">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currency',
|
||||
header: 'Currency',
|
||||
accessorKey: 'currency',
|
||||
size: 70,
|
||||
cell: ({ getValue, cell }) => (
|
||||
<span
|
||||
className="font-mono text-text-secondary text-sm"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
accessorKey: 'active',
|
||||
size: 80,
|
||||
maxSize: 80,
|
||||
cell: ({ getValue, row, cell }) => {
|
||||
const isActive = getValue() as boolean;
|
||||
return (
|
||||
<label
|
||||
className="relative inline-flex items-center cursor-pointer"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
<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: 150,
|
||||
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;
|
||||
const providers = row.original.providers;
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-400">
|
||||
<CheckIcon className="h-3 w-3 inline mr-1" />
|
||||
{activeMappings} active
|
||||
</span>
|
||||
<span className="text-blue-400">
|
||||
✓ {verifiedMappings} verified
|
||||
</span>
|
||||
</div>
|
||||
{providers && (
|
||||
<div className="text-xs text-text-muted truncate" title={providers}>
|
||||
{providers}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
onClick={() => handleAddProviderMapping(row.original.id, row.original.name)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
||||
title="Add Provider Mapping"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add Mapping
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
accessorKey: 'updated_at',
|
||||
size: 120,
|
||||
maxSize: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-muted">
|
||||
{new Date(getValue() as string).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
editingCell,
|
||||
editValue,
|
||||
expandedRows,
|
||||
handleCellEdit,
|
||||
handleToggleActive,
|
||||
handleAddProviderMapping,
|
||||
handleToggleExpandRow,
|
||||
]);
|
||||
|
||||
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 renderExpandedRow = (exchange: Exchange) => {
|
||||
const mappings = expandedRowData[exchange.id] || [];
|
||||
|
||||
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: {new Date(mapping.created_at).toLocaleDateString()}</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 (
|
||||
<>
|
||||
<div className="space-y-0">
|
||||
<DataTable
|
||||
data={exchanges || []}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
className="rounded-lg border border-border"
|
||||
/>
|
||||
|
||||
{/* Expanded rows */}
|
||||
{Array.from(expandedRows).map(exchangeId => {
|
||||
const exchange = exchanges?.find(e => e.id === exchangeId);
|
||||
if (!exchange) return null;
|
||||
|
||||
return (
|
||||
<div key={`expanded-${exchangeId}`} className="border-l border-r border-b border-border rounded-b-lg -mt-1">
|
||||
{renderExpandedRow(exchange)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
apps/web-app/src/features/exchanges/components/index.ts
Normal file
3
apps/web-app/src/features/exchanges/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { AddSourceDialog } from './AddSourceDialog';
|
||||
export { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
||||
export { ExchangesTable } from './ExchangesTable';
|
||||
1
apps/web-app/src/features/exchanges/hooks/index.ts
Normal file
1
apps/web-app/src/features/exchanges/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useExchanges } from './useExchanges';
|
||||
269
apps/web-app/src/features/exchanges/hooks/useExchanges.ts
Normal file
269
apps/web-app/src/features/exchanges/hooks/useExchanges.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
CreateProviderMappingRequest,
|
||||
Exchange,
|
||||
ExchangeDetails,
|
||||
ExchangeStats,
|
||||
ProviderMapping,
|
||||
ProviderExchange,
|
||||
UpdateExchangeRequest,
|
||||
UpdateProviderMappingRequest,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:4000/api';
|
||||
|
||||
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 response = await fetch(`${API_BASE_URL}/exchanges`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch exchanges: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setExchanges(data.data || []);
|
||||
} else {
|
||||
throw new Error(data.error || 'API returned error status');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching exchanges:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
|
||||
setExchanges([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateExchange = useCallback(
|
||||
async (id: string, updates: UpdateExchangeRequest) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update exchange: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update exchange');
|
||||
}
|
||||
|
||||
// Refresh the exchanges list
|
||||
await fetchExchanges();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error updating exchange:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to update exchange');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchExchanges]
|
||||
);
|
||||
|
||||
const fetchExchangeDetails = useCallback(async (id: string): Promise<ExchangeDetails | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch exchange details: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch exchange details');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching exchange details:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchange details');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async (): Promise<ExchangeStats | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/stats/summary`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch stats');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchProviderMappings = useCallback(
|
||||
async (provider?: string): Promise<ProviderMapping[]> => {
|
||||
try {
|
||||
const url = provider
|
||||
? `${API_BASE_URL}/exchanges/provider-mappings/${provider}`
|
||||
: `${API_BASE_URL}/exchanges/provider-mappings/all`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch provider mappings: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch provider mappings');
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching provider mappings:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch provider mappings');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateProviderMapping = useCallback(
|
||||
async (id: string, updates: UpdateProviderMappingRequest) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-mappings/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update provider mapping: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider mapping');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error updating provider mapping:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to update provider mapping');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createProviderMapping = useCallback(async (request: CreateProviderMappingRequest) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-mappings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create provider mapping: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create provider mapping');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error('Error creating provider mapping:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create provider mapping');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchProviders = useCallback(async (): Promise<string[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/providers/list`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch providers: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch providers');
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching providers:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUnmappedProviderExchanges = useCallback(
|
||||
async (provider: string): Promise<ProviderExchange[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-exchanges/unmapped/${provider}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch unmapped exchanges: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch unmapped exchanges');
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching unmapped exchanges:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch unmapped exchanges');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExchanges();
|
||||
}, [fetchExchanges]);
|
||||
|
||||
return {
|
||||
exchanges,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchExchanges,
|
||||
updateExchange,
|
||||
fetchExchangeDetails,
|
||||
fetchStats,
|
||||
fetchProviderMappings,
|
||||
updateProviderMapping,
|
||||
createProviderMapping,
|
||||
fetchProviders,
|
||||
fetchUnmappedProviderExchanges,
|
||||
};
|
||||
}
|
||||
4
apps/web-app/src/features/exchanges/index.ts
Normal file
4
apps/web-app/src/features/exchanges/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './components';
|
||||
export { ExchangesPage } from './ExchangesPage';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
89
apps/web-app/src/features/exchanges/types/index.ts
Normal file
89
apps/web-app/src/features/exchanges/types/index.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export interface ProviderMapping {
|
||||
id: string;
|
||||
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;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
master_exchange_code?: string;
|
||||
master_exchange_name?: string;
|
||||
master_exchange_active?: boolean;
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
provider_mapping_count: string;
|
||||
active_mapping_count: string;
|
||||
verified_mapping_count: string;
|
||||
providers: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeDetails {
|
||||
exchange: Exchange;
|
||||
provider_mappings: ProviderMapping[];
|
||||
}
|
||||
|
||||
export interface ExchangesApiResponse {
|
||||
success: boolean;
|
||||
data: Exchange[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateExchangeRequest {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
country?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProviderMappingRequest {
|
||||
active?: boolean;
|
||||
verified?: boolean;
|
||||
confidence?: number;
|
||||
master_exchange_id?: 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 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;
|
||||
}
|
||||
|
||||
export interface ProviderExchange {
|
||||
provider_exchange_code: string;
|
||||
provider_exchange_name: string;
|
||||
country_code: string | null;
|
||||
currency: string | null;
|
||||
symbol_count: number | null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue