initial work on exchanges page
This commit is contained in:
parent
3f5bbc6345
commit
d7780e9684
18 changed files with 822 additions and 41 deletions
|
|
@ -22,10 +22,10 @@ const app = new Hono();
|
|||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: ['http://localhost:4200', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
credentials: false,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,31 @@
|
|||
import { Layout } from '@/components/layout';
|
||||
import { DashboardPage } from '@/features/dashboard';
|
||||
import { ExchangesPage } from '@/features/exchanges';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Layout title="Dashboard">
|
||||
<DashboardPage />
|
||||
</Layout>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="exchanges" element={<ExchangesPage />} />
|
||||
<Route
|
||||
path="portfolio"
|
||||
element={<div className="p-4">Portfolio Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route
|
||||
path="strategies"
|
||||
element={<div className="p-4">Strategies Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route
|
||||
path="analytics"
|
||||
element={<div className="p-4">Analytics Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import { ReactNode, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Layout({ children, title }: LayoutProps) {
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Determine title from current route
|
||||
const getTitle = () => {
|
||||
const path = location.pathname.replace('/', '');
|
||||
if (!path || path === 'dashboard') return 'Dashboard';
|
||||
return path.charAt(0).toUpperCase() + path.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
<Header setSidebarOpen={setSidebarOpen} title={title} />
|
||||
<Header setSidebarOpen={setSidebarOpen} title={getTitle()} />
|
||||
|
||||
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto scrollbar-sleek">
|
||||
<div className="px-4 flex-col h-full">{children}</div>
|
||||
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto">
|
||||
<div className="px-4 flex-col h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
|||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Fragment } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean;
|
||||
|
|
@ -86,18 +87,22 @@ function SidebarContent() {
|
|||
<ul role="list" className="-mx-1 space-y-0.5">
|
||||
{navigation.map(item => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={cn(
|
||||
item.current
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
isActive
|
||||
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
||||
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||
)}
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon
|
||||
className={cn(
|
||||
item.current
|
||||
isActive
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-4 w-4 shrink-0 transition-colors'
|
||||
|
|
@ -105,7 +110,9 @@ function SidebarContent() {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -80,12 +80,12 @@ export function DataTable<T>({
|
|||
Table: ({ style, ...props }) => (
|
||||
<table
|
||||
{...props}
|
||||
style={{
|
||||
{...{
|
||||
style: {
|
||||
...style,
|
||||
width: table.getCenterTotalSize(),
|
||||
minWidth: '100%',
|
||||
tableLayout: 'auto',
|
||||
borderCollapse: 'collapse',
|
||||
borderSpacing: 0,
|
||||
},
|
||||
}}
|
||||
className="bg-background"
|
||||
/>
|
||||
|
|
|
|||
16
apps/web/src/features/exchanges/ExchangesPage.tsx
Normal file
16
apps/web/src/features/exchanges/ExchangesPage.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { ExchangesTable } from './components/ExchangesTable';
|
||||
|
||||
export function ExchangesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
||||
<ExchangesTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
apps/web/src/features/exchanges/components/AddSourceDialog.tsx
Normal file
198
apps/web/src/features/exchanges/components/AddSourceDialog.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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,
|
||||
exchangeId,
|
||||
exchangeName,
|
||||
}: AddSourceDialogProps) {
|
||||
const [source, setSource] = 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 || !id || !name || !code) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onAddSource({
|
||||
source,
|
||||
mapping: {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
aliases: aliases
|
||||
.split(',')
|
||||
.map(a => a.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setSource('');
|
||||
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 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 || !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>
|
||||
);
|
||||
}
|
||||
292
apps/web/src/features/exchanges/components/ExchangesTable.tsx
Normal file
292
apps/web/src/features/exchanges/components/ExchangesTable.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { DataTable } from '@/components/ui';
|
||||
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useExchanges } from '../hooks/useExchanges';
|
||||
import { Exchange, SourceMapping } from '../types';
|
||||
import { AddSourceDialog } from './AddSourceDialog';
|
||||
|
||||
export function ExchangesTable() {
|
||||
const { exchanges, loading, error, updateExchange, addSource, removeSource } = useExchanges();
|
||||
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [addSourceDialog, setAddSourceDialog] = useState<{
|
||||
id: string;
|
||||
exchangeName: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleCellEdit = useCallback(
|
||||
async (id: string, field: string, value: string) => {
|
||||
if (field === 'shortName') {
|
||||
await updateExchange(id, { shortName: value });
|
||||
}
|
||||
setEditingCell(null);
|
||||
setEditValue('');
|
||||
},
|
||||
[updateExchange]
|
||||
);
|
||||
|
||||
const handleToggleActive = useCallback(
|
||||
async (id: string, currentStatus: boolean) => {
|
||||
await updateExchange(id, { active: !currentStatus });
|
||||
},
|
||||
[updateExchange]
|
||||
);
|
||||
|
||||
const handleAddSource = useCallback(async (id: string, exchangeName: string) => {
|
||||
setAddSourceDialog({ id, exchangeName });
|
||||
}, []);
|
||||
|
||||
const handleRemoveSource = useCallback(
|
||||
async (exchangeId: string, sourceName: string) => {
|
||||
if (confirm(`Are you sure you want to remove the ${sourceName} source?`)) {
|
||||
await removeSource(exchangeId, sourceName);
|
||||
}
|
||||
},
|
||||
[removeSource]
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'masterExchangeId',
|
||||
header: 'Master ID',
|
||||
accessorKey: 'masterExchangeId',
|
||||
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: 'shortName',
|
||||
header: 'Short Name',
|
||||
accessorKey: 'shortName',
|
||||
size: 50,
|
||||
enableResizing: false,
|
||||
cell: ({ getValue, row, cell }) => {
|
||||
const isEditing =
|
||||
editingCell?.id === row.original._id && editingCell?.field === 'shortName';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={() => handleCellEdit(row.original._id, 'shortName', editValue)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCellEdit(row.original._id, 'shortName', 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: 'shortName' });
|
||||
setEditValue(getValue() as string);
|
||||
}}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'officialName',
|
||||
header: 'Official Name',
|
||||
accessorKey: 'officialName',
|
||||
size: 150,
|
||||
maxSize: 150,
|
||||
enableResizing: true,
|
||||
cell: ({ getValue, cell }) => (
|
||||
<span
|
||||
className="text-text-primary text-sm truncate block"
|
||||
title={getValue() as string}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
header: 'Country',
|
||||
accessorKey: 'country',
|
||||
size: 40,
|
||||
maxSize: 40,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-text-secondary text-sm">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currency',
|
||||
header: 'Currency',
|
||||
accessorKey: 'currency',
|
||||
size: 40,
|
||||
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: 'sources',
|
||||
header: 'Sources',
|
||||
accessorKey: 'sourceMappings',
|
||||
minSize: 400,
|
||||
maxSize: 400,
|
||||
size: 400,
|
||||
enableResizing: true,
|
||||
cell: ({ getValue, row, cell }) => {
|
||||
const sourceMappings = getValue() as Record<string, SourceMapping>;
|
||||
const sources = Object.keys(sourceMappings);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" style={{ width: cell.column.getSize() }}>
|
||||
{sources.map(source => (
|
||||
<span
|
||||
key={source}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-secondary rounded text-xs"
|
||||
>
|
||||
{source.toUpperCase()}
|
||||
<button
|
||||
onClick={() => handleRemoveSource(row.original._id, source)}
|
||||
className="text-danger hover:text-danger/80 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleAddSource(row.original._id, row.original.officialName)}
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
accessorKey: 'updated_at',
|
||||
size: 150,
|
||||
maxSize: 150,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-muted">
|
||||
{new Date(getValue() as string).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
editingCell,
|
||||
editValue,
|
||||
handleCellEdit,
|
||||
handleRemoveSource,
|
||||
handleToggleActive,
|
||||
handleAddSource,
|
||||
]);
|
||||
|
||||
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 data-service is running on localhost:2001
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-primary">Exchanges Management</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Manage exchange configurations and source mappings
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">{exchanges?.length || 0} exchanges loaded</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={exchanges || []}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
className="rounded-lg border border-border"
|
||||
/>
|
||||
|
||||
<div className="mt-2 text-xs text-text-muted">
|
||||
Debug: Data length: {exchanges?.length || 0}, Loading: {loading.toString()}, Error:{' '}
|
||||
{error || 'none'}
|
||||
</div>
|
||||
|
||||
{addSourceDialog && (
|
||||
<AddSourceDialog
|
||||
isOpen={true}
|
||||
exchangeId={addSourceDialog.id}
|
||||
exchangeName={addSourceDialog.exchangeName}
|
||||
onClose={() => setAddSourceDialog(null)}
|
||||
onAddSource={async (sourceRequest: {
|
||||
source: string;
|
||||
mapping: { id: string; name: string; code: string; aliases: string[] };
|
||||
}) => {
|
||||
const success = await addSource(addSourceDialog.id, sourceRequest);
|
||||
if (success) {
|
||||
setAddSourceDialog(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/features/exchanges/components/index.ts
Normal file
2
apps/web/src/features/exchanges/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AddSourceDialog } from './AddSourceDialog';
|
||||
export { ExchangesTable } from './ExchangesTable';
|
||||
1
apps/web/src/features/exchanges/hooks/index.ts
Normal file
1
apps/web/src/features/exchanges/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useExchanges } from './useExchanges';
|
||||
132
apps/web/src/features/exchanges/hooks/useExchanges.ts
Normal file
132
apps/web/src/features/exchanges/hooks/useExchanges.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AddSourceRequest, Exchange, UpdateExchangeRequest } from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:2001/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.status === 'success') {
|
||||
// The API returns exchanges directly in data.data array
|
||||
setExchanges(data.data || []);
|
||||
} else {
|
||||
throw new Error('API returned error status');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching exchanges:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
|
||||
setExchanges([]); // Reset to empty array on error
|
||||
} 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}`);
|
||||
}
|
||||
|
||||
// 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 addSource = useCallback(
|
||||
async (exchangeId: string, sourceRequest: AddSourceRequest) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/exchanges/${exchangeId}/sources`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(sourceRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add source: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Refresh the exchanges list
|
||||
await fetchExchanges();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error adding source:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to add source');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchExchanges]
|
||||
);
|
||||
|
||||
const removeSource = useCallback(
|
||||
async (exchangeId: string, sourceName: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/exchanges/${exchangeId}/sources/${sourceName}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to remove source: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Refresh the exchanges list
|
||||
await fetchExchanges();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error removing source:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove source');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchExchanges]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExchanges();
|
||||
}, [fetchExchanges]);
|
||||
|
||||
return {
|
||||
exchanges,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchExchanges,
|
||||
updateExchange,
|
||||
addSource,
|
||||
removeSource,
|
||||
};
|
||||
}
|
||||
4
apps/web/src/features/exchanges/index.ts
Normal file
4
apps/web/src/features/exchanges/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './components';
|
||||
export { ExchangesPage } from './ExchangesPage';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
45
apps/web/src/features/exchanges/types/index.ts
Normal file
45
apps/web/src/features/exchanges/types/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export interface SourceMapping {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
aliases: string[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
_id: string;
|
||||
masterExchangeId: string;
|
||||
shortName: string;
|
||||
officialName: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
active: boolean;
|
||||
sourceMappings: Record<string, SourceMapping>;
|
||||
confidence: number;
|
||||
verified: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExchangesApiResponse {
|
||||
status: string;
|
||||
data: Exchange[]; // Exchanges are directly in data array
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UpdateExchangeRequest {
|
||||
shortName?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface AddSourceRequest {
|
||||
source: string;
|
||||
mapping: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
aliases: string[];
|
||||
};
|
||||
}
|
||||
17
apps/web/src/lib/constants.ts
Normal file
17
apps/web/src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {
|
||||
BuildingLibraryIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon,
|
||||
DocumentTextIcon,
|
||||
HomeIcon,
|
||||
PresentationChartLineIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
|
||||
{ name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon },
|
||||
{ name: 'Strategies', href: '/strategies', icon: DocumentTextIcon },
|
||||
{ name: 'Analytics', href: '/analytics', icon: PresentationChartLineIcon },
|
||||
{ name: 'Settings', href: '/settings', icon: CogIcon },
|
||||
];
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
BuildingOfficeIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon,
|
||||
CurrencyDollarIcon,
|
||||
|
|
@ -25,6 +26,12 @@ export const navigation = [
|
|||
icon: ChartBarIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Exchanges',
|
||||
href: '/exchanges',
|
||||
icon: BuildingOfficeIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/reports',
|
||||
|
|
|
|||
25
apps/web/src/lib/utils.ts
Normal file
25
apps/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Utility functions for financial data formatting
|
||||
export function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatPercentage(value: number): string {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function getValueColor(value: number): string {
|
||||
if (value > 0) return 'text-success';
|
||||
if (value < 0) return 'text-danger';
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
7
bun.lock
7
bun.lock
|
|
@ -143,6 +143,7 @@
|
|||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -750,6 +751,8 @@
|
|||
|
||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
|
@ -770,6 +773,10 @@
|
|||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="],
|
||||
|
||||
"@types/react-router-dom": ["@types/react-router-dom@5.3.3", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "*" } }, "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="],
|
||||
|
||||
"@types/react-virtualized-auto-sizer": ["@types/react-virtualized-auto-sizer@1.0.8", "", { "dependencies": { "react-virtualized-auto-sizer": "*" } }, "sha512-keJpNyhiwfl2+N12G1ocCVA5ZDBArbPLe/S90X3kt7fam9naeHdaYYWbpe2sHczp70JWJ+2QLhBE8kLvLuVNjA=="],
|
||||
|
||||
"@types/react-window": ["@types/react-window@1.8.8", "", { "dependencies": { "@types/react": "*" } }, "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q=="],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue