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(
|
app.use(
|
||||||
'*',
|
'*',
|
||||||
cors({
|
cors({
|
||||||
origin: ['http://localhost:4200', 'http://localhost:5173'],
|
origin: '*',
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization'],
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
credentials: true,
|
credentials: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,31 @@
|
||||||
import { Layout } from '@/components/layout';
|
import { Layout } from '@/components/layout';
|
||||||
import { DashboardPage } from '@/features/dashboard';
|
import { DashboardPage } from '@/features/dashboard';
|
||||||
|
import { ExchangesPage } from '@/features/exchanges';
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Layout title="Dashboard">
|
<BrowserRouter>
|
||||||
<DashboardPage />
|
<Routes>
|
||||||
</Layout>
|
<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 { Header } from './Header';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
interface LayoutProps {
|
export function Layout() {
|
||||||
children: ReactNode;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children, title }: LayoutProps) {
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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 (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<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">
|
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto">
|
||||||
<div className="px-4 flex-col h-full">{children}</div>
|
<div className="px-4 flex-col h-full">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
|
@ -86,18 +87,22 @@ function SidebarContent() {
|
||||||
<ul role="list" className="-mx-1 space-y-0.5">
|
<ul role="list" className="-mx-1 space-y-0.5">
|
||||||
{navigation.map(item => (
|
{navigation.map(item => (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
<a
|
<NavLink
|
||||||
href={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={({ isActive }) =>
|
||||||
item.current
|
cn(
|
||||||
|
isActive
|
||||||
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
||||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
: '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'
|
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
<item.icon
|
<item.icon
|
||||||
className={cn(
|
className={cn(
|
||||||
item.current
|
isActive
|
||||||
? 'text-primary-400'
|
? 'text-primary-400'
|
||||||
: 'text-text-muted group-hover:text-primary-400',
|
: 'text-text-muted group-hover:text-primary-400',
|
||||||
'h-4 w-4 shrink-0 transition-colors'
|
'h-4 w-4 shrink-0 transition-colors'
|
||||||
|
|
@ -105,7 +110,9 @@ function SidebarContent() {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,12 @@ export function DataTable<T>({
|
||||||
Table: ({ style, ...props }) => (
|
Table: ({ style, ...props }) => (
|
||||||
<table
|
<table
|
||||||
{...props}
|
{...props}
|
||||||
style={{
|
{...{
|
||||||
|
style: {
|
||||||
...style,
|
...style,
|
||||||
|
width: table.getCenterTotalSize(),
|
||||||
minWidth: '100%',
|
minWidth: '100%',
|
||||||
tableLayout: 'auto',
|
},
|
||||||
borderCollapse: 'collapse',
|
|
||||||
borderSpacing: 0,
|
|
||||||
}}
|
}}
|
||||||
className="bg-background"
|
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 {
|
import {
|
||||||
|
BuildingOfficeIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
CurrencyDollarIcon,
|
CurrencyDollarIcon,
|
||||||
|
|
@ -25,6 +26,12 @@ export const navigation = [
|
||||||
icon: ChartBarIcon,
|
icon: ChartBarIcon,
|
||||||
current: false,
|
current: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Exchanges',
|
||||||
|
href: '/exchanges',
|
||||||
|
icon: BuildingOfficeIcon,
|
||||||
|
current: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Reports',
|
name: 'Reports',
|
||||||
href: '/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",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -750,6 +751,8 @@
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
"@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/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=="],
|
"@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-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-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=="],
|
"@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