diff --git a/apps/data-service/src/index.ts b/apps/data-service/src/index.ts index a516a13..c3b1b10 100644 --- a/apps/data-service/src/index.ts +++ b/apps/data-service/src/index.ts @@ -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, }) ); diff --git a/apps/web/package.json b/apps/web/package.json index 6d5563d..83b3112 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index de4df77..e73c2b0 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -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 ( - - - + + + }> + } /> + } /> + } /> + Portfolio Page - Coming Soon} + /> + Strategies Page - Coming Soon} + /> + Analytics Page - Coming Soon} + /> + Settings Page - Coming Soon} /> + + + ); } diff --git a/apps/web/src/components/layout/Layout.tsx b/apps/web/src/components/layout/Layout.tsx index e11b343..24b711f 100644 --- a/apps/web/src/components/layout/Layout.tsx +++ b/apps/web/src/components/layout/Layout.tsx @@ -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 (
-
+
-
-
{children}
+
+
+ +
); diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index b4263dc..f6822b0 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -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,26 +87,32 @@ function SidebarContent() { diff --git a/apps/web/src/components/ui/DataTable/DataTable.tsx b/apps/web/src/components/ui/DataTable/DataTable.tsx index 64d87e2..7e9e164 100644 --- a/apps/web/src/components/ui/DataTable/DataTable.tsx +++ b/apps/web/src/components/ui/DataTable/DataTable.tsx @@ -80,12 +80,12 @@ export function DataTable({ Table: ({ style, ...props }) => ( diff --git a/apps/web/src/features/exchanges/ExchangesPage.tsx b/apps/web/src/features/exchanges/ExchangesPage.tsx new file mode 100644 index 0000000..4dc71cc --- /dev/null +++ b/apps/web/src/features/exchanges/ExchangesPage.tsx @@ -0,0 +1,16 @@ +import { ExchangesTable } from './components/ExchangesTable'; + +export function ExchangesPage() { + return ( +
+
+

Exchange Management

+

+ Configure and manage master exchanges with their data sources and providers. +

+
+ + +
+ ); +} diff --git a/apps/web/src/features/exchanges/components/AddSourceDialog.tsx b/apps/web/src/features/exchanges/components/AddSourceDialog.tsx new file mode 100644 index 0000000..70d2f57 --- /dev/null +++ b/apps/web/src/features/exchanges/components/AddSourceDialog.tsx @@ -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; + 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 ( + + + +
+ + +
+
+ + +
+ + Add Source to {exchangeName} + + +
+ +
+
+ + +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/exchanges/components/ExchangesTable.tsx b/apps/web/src/features/exchanges/components/ExchangesTable.tsx new file mode 100644 index 0000000..d604bd6 --- /dev/null +++ b/apps/web/src/features/exchanges/components/ExchangesTable.tsx @@ -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[]>(() => { + return [ + { + id: 'masterExchangeId', + header: 'Master ID', + accessorKey: 'masterExchangeId', + size: 50, + enableResizing: false, + cell: ({ getValue, cell }) => ( + + {getValue() as string} + + ), + }, + { + 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 ( + 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 ( +
{ + setEditingCell({ id: row.original._id, field: 'shortName' }); + setEditValue(getValue() as string); + }} + > + {getValue() as string} +
+ ); + }, + }, + { + id: 'officialName', + header: 'Official Name', + accessorKey: 'officialName', + size: 150, + maxSize: 150, + enableResizing: true, + cell: ({ getValue, cell }) => ( + + {getValue() as string} + + ), + }, + { + id: 'country', + header: 'Country', + accessorKey: 'country', + size: 40, + maxSize: 40, + cell: ({ getValue }) => ( + {getValue() as string} + ), + }, + { + id: 'currency', + header: 'Currency', + accessorKey: 'currency', + size: 40, + cell: ({ getValue, cell }) => ( + + {getValue() as string} + + ), + }, + { + id: 'active', + header: 'Active', + accessorKey: 'active', + size: 80, + maxSize: 80, + cell: ({ getValue, row, cell }) => { + const isActive = getValue() as boolean; + return ( + + ); + }, + }, + { + id: 'sources', + header: 'Sources', + accessorKey: 'sourceMappings', + minSize: 400, + maxSize: 400, + size: 400, + enableResizing: true, + cell: ({ getValue, row, cell }) => { + const sourceMappings = getValue() as Record; + const sources = Object.keys(sourceMappings); + + return ( +
+ {sources.map(source => ( + + {source.toUpperCase()} + + + ))} + +
+ ); + }, + }, + { + id: 'updated_at', + header: 'Last Updated', + accessorKey: 'updated_at', + size: 150, + maxSize: 150, + cell: ({ getValue }) => ( + + {new Date(getValue() as string).toLocaleDateString()} + + ), + }, + ]; + }, [ + editingCell, + editValue, + handleCellEdit, + handleRemoveSource, + handleToggleActive, + handleAddSource, + ]); + + if (error) { + return ( +
+

Error Loading Exchanges

+

{error}

+

+ Make sure the data-service is running on localhost:2001 +

+
+ ); + } + + return ( +
+
+
+

Exchanges Management

+

+ Manage exchange configurations and source mappings +

+
+
{exchanges?.length || 0} exchanges loaded
+
+ + + +
+ Debug: Data length: {exchanges?.length || 0}, Loading: {loading.toString()}, Error:{' '} + {error || 'none'} +
+ + {addSourceDialog && ( + 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); + } + }} + /> + )} +
+ ); +} diff --git a/apps/web/src/features/exchanges/components/index.ts b/apps/web/src/features/exchanges/components/index.ts new file mode 100644 index 0000000..f056547 --- /dev/null +++ b/apps/web/src/features/exchanges/components/index.ts @@ -0,0 +1,2 @@ +export { AddSourceDialog } from './AddSourceDialog'; +export { ExchangesTable } from './ExchangesTable'; diff --git a/apps/web/src/features/exchanges/hooks/index.ts b/apps/web/src/features/exchanges/hooks/index.ts new file mode 100644 index 0000000..7cc97c9 --- /dev/null +++ b/apps/web/src/features/exchanges/hooks/index.ts @@ -0,0 +1 @@ +export { useExchanges } from './useExchanges'; diff --git a/apps/web/src/features/exchanges/hooks/useExchanges.ts b/apps/web/src/features/exchanges/hooks/useExchanges.ts new file mode 100644 index 0000000..b0ef638 --- /dev/null +++ b/apps/web/src/features/exchanges/hooks/useExchanges.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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, + }; +} diff --git a/apps/web/src/features/exchanges/index.ts b/apps/web/src/features/exchanges/index.ts new file mode 100644 index 0000000..a5be8e2 --- /dev/null +++ b/apps/web/src/features/exchanges/index.ts @@ -0,0 +1,4 @@ +export * from './components'; +export { ExchangesPage } from './ExchangesPage'; +export * from './hooks'; +export * from './types'; diff --git a/apps/web/src/features/exchanges/types/index.ts b/apps/web/src/features/exchanges/types/index.ts new file mode 100644 index 0000000..7c9574c --- /dev/null +++ b/apps/web/src/features/exchanges/types/index.ts @@ -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; + 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[]; + }; +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts new file mode 100644 index 0000000..f1478d5 --- /dev/null +++ b/apps/web/src/lib/constants.ts @@ -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 }, +]; diff --git a/apps/web/src/lib/constants/navigation.ts b/apps/web/src/lib/constants/navigation.ts index f0384cf..3bd3353 100644 --- a/apps/web/src/lib/constants/navigation.ts +++ b/apps/web/src/lib/constants/navigation.ts @@ -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', diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..6b52e6e --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -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'; +} diff --git a/bun.lock b/bun.lock index a623467..b155d09 100644 --- a/bun.lock +++ b/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=="],