finished initial symbols pages
This commit is contained in:
parent
62706cdb42
commit
4e4a048988
13 changed files with 688 additions and 107 deletions
|
|
@ -4,7 +4,7 @@ import { ExchangesPage } from '@/features/exchanges';
|
|||
import { MonitoringPage } from '@/features/monitoring';
|
||||
import { PipelinePage } from '@/features/pipeline';
|
||||
import { BacktestPage } from '@/features/backtest';
|
||||
import { ChartPage } from '@/features/charts';
|
||||
import { SymbolsPage, SymbolDetailPage } from '@/features/symbols';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
export function App() {
|
||||
|
|
@ -14,7 +14,10 @@ export function App() {
|
|||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="charts" element={<ChartPage />} />
|
||||
<Route path="symbols">
|
||||
<Route index element={<SymbolsPage />} />
|
||||
<Route path=":symbol" element={<SymbolDetailPage />} />
|
||||
</Route>
|
||||
<Route path="exchanges" element={<ExchangesPage />} />
|
||||
<Route
|
||||
path="portfolio"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { navigation } from '@/lib/constants';
|
|||
import type { NavigationItem } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Fragment } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
interface SidebarProps {
|
||||
|
|
@ -78,29 +78,6 @@ export function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||
|
||||
function SidebarContent() {
|
||||
const location = useLocation();
|
||||
|
||||
// Auto-expand items that have active children
|
||||
const getInitialExpanded = () => {
|
||||
const expanded = new Set<string>();
|
||||
navigation.forEach(item => {
|
||||
if (item.children && item.children.some(child => location.pathname === child.href)) {
|
||||
expanded.add(item.name);
|
||||
}
|
||||
});
|
||||
return expanded;
|
||||
};
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(getInitialExpanded());
|
||||
|
||||
const toggleExpanded = (name: string) => {
|
||||
const newExpanded = new Set(expandedItems);
|
||||
if (newExpanded.has(name)) {
|
||||
newExpanded.delete(name);
|
||||
} else {
|
||||
newExpanded.add(name);
|
||||
}
|
||||
setExpandedItems(newExpanded);
|
||||
};
|
||||
|
||||
const isChildActive = (children: NavigationItem[]) => {
|
||||
return children.some(child => location.pathname === child.href);
|
||||
|
|
@ -119,12 +96,11 @@ function SidebarContent() {
|
|||
<li key={item.name}>
|
||||
{item.children ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleExpanded(item.name)}
|
||||
<div
|
||||
className={cn(
|
||||
isChildActive(item.children)
|
||||
? 'bg-surface-secondary text-primary-400'
|
||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
||||
: 'text-text-secondary',
|
||||
'group flex w-full gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||
)}
|
||||
>
|
||||
|
|
@ -132,52 +108,45 @@ function SidebarContent() {
|
|||
className={cn(
|
||||
isChildActive(item.children)
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
: 'text-text-muted',
|
||||
'h-4 w-4 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1 text-left">{item.name}</span>
|
||||
{expandedItems.has(item.name) ? (
|
||||
<ChevronDownIcon className="h-3 w-3 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedItems.has(item.name) && (
|
||||
<ul className="mt-1 space-y-0.5 pl-8">
|
||||
{item.children.map(child => (
|
||||
<li key={child.name}>
|
||||
<NavLink
|
||||
to={child.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 text-sm leading-tight font-medium transition-colors'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<child.icon
|
||||
className={cn(
|
||||
isActive
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-3 w-3 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{child.name}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5 pl-5">
|
||||
{item.children.map(child => (
|
||||
<li key={child.name}>
|
||||
<NavLink
|
||||
to={child.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 text-sm leading-tight font-medium transition-colors'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<child.icon
|
||||
className={cn(
|
||||
isActive
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-3 w-3 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{child.name}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<NavLink
|
||||
|
|
|
|||
|
|
@ -42,9 +42,14 @@ function generateMockData(days: number = 365): CandlestickData[] {
|
|||
return data;
|
||||
}
|
||||
|
||||
export function ChartPage() {
|
||||
interface ChartPageProps {
|
||||
symbol?: string;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function ChartPage({ symbol = 'AAPL', showHeader = true }: ChartPageProps = {}) {
|
||||
const [config, setConfig] = useState<ChartConfig>({
|
||||
symbol: 'AAPL',
|
||||
symbol,
|
||||
interval: '1d',
|
||||
chartType: 'candlestick',
|
||||
showVolume: true,
|
||||
|
|
@ -201,14 +206,16 @@ export function ChartPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2 px-8 pt-4">Market Charts</h1>
|
||||
<p className="text-text-secondary mb-4 text-sm px-8">
|
||||
Real-time market data and advanced charting powered by TradingView.
|
||||
</p>
|
||||
</div>
|
||||
{showHeader && (
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2 px-8 pt-4">Market Charts</h1>
|
||||
<p className="text-text-secondary mb-4 text-sm px-8">
|
||||
Real-time market data and advanced charting powered by TradingView.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex bg-surface-secondary rounded-lg border border-border mx-8 mb-8 overflow-hidden">
|
||||
<div className={`flex-1 flex bg-surface-secondary ${showHeader ? 'rounded-lg border border-border mx-8 mb-8' : ''} overflow-hidden`}>
|
||||
{/* Left Sidebar - Indicator List */}
|
||||
{showSidebar && (
|
||||
<IndicatorList
|
||||
|
|
|
|||
|
|
@ -27,19 +27,8 @@ const chartTypes: { value: ChartType; label: string; icon?: typeof ChartBarIcon
|
|||
|
||||
export function ChartToolbar({ config, onConfigChange }: ChartToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 p-4 bg-surface-secondary border-b border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Symbol Input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.symbol}
|
||||
onChange={(e) => onConfigChange({ symbol: e.target.value.toUpperCase() })}
|
||||
placeholder="Symbol"
|
||||
className="px-3 py-1.5 bg-background border border-border rounded-md text-sm font-medium text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 bg-surface-secondary border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Interval Selector */}
|
||||
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
|
||||
{intervals.map(({ value, label }) => (
|
||||
|
|
@ -76,19 +65,6 @@ export function ChartToolbar({ config, onConfigChange }: ChartToolbarProps) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Volume Toggle */}
|
||||
<button
|
||||
onClick={() => onConfigChange({ showVolume: !config.showVolume })}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md border transition-colors ${
|
||||
config.showVolume
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'bg-background text-text-secondary border-border hover:text-text-primary hover:border-primary-500/50'
|
||||
}`}
|
||||
>
|
||||
Volume
|
||||
</button>
|
||||
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={() => onConfigChange({ theme: config.theme === 'dark' ? 'light' : 'dark' })}
|
||||
|
|
|
|||
221
apps/stock/web-app/src/features/symbols/SymbolDetailPage.tsx
Normal file
221
apps/stock/web-app/src/features/symbols/SymbolDetailPage.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChartPage } from '../charts/ChartPage';
|
||||
import type { Symbol } from './types';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chart', name: 'Chart' },
|
||||
{ id: 'financials', name: 'Financials' },
|
||||
{ id: 'filings', name: 'Filings' },
|
||||
{ id: 'insiders', name: 'Insiders' },
|
||||
{ id: 'shorts', name: 'Shorts' },
|
||||
{ id: 'sentiment', name: 'Sentiment' },
|
||||
{ id: 'news', name: 'News' },
|
||||
{ id: 'info', name: 'Info' },
|
||||
];
|
||||
|
||||
// Mock symbol data - in real app would fetch from API
|
||||
const mockSymbolData: Record<string, Symbol> = {
|
||||
'AAPL': { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.48e12, sector: 'Technology', industry: 'Consumer Electronics' },
|
||||
'SHOP': { symbol: 'SHOP', name: 'Shopify Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 135e9, sector: 'Technology', industry: 'E-Commerce Software' },
|
||||
'TD': { symbol: 'TD', name: 'Toronto-Dominion Bank', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 150e9, sector: 'Financial', industry: 'Banks' },
|
||||
};
|
||||
|
||||
export function SymbolDetailPage() {
|
||||
const { symbol } = useParams<{ symbol: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('chart');
|
||||
const [symbolData, setSymbolData] = useState<Symbol | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call to fetch symbol data
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
const data = mockSymbolData[symbol || ''] || {
|
||||
symbol: symbol || 'UNKNOWN',
|
||||
name: 'Unknown Company',
|
||||
exchange: 'UNKNOWN',
|
||||
country: 'US',
|
||||
type: 'Common Stock',
|
||||
currency: 'USD',
|
||||
};
|
||||
setSymbolData(data);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, [symbol]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!symbolData) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-text-secondary mb-4">Symbol not found</p>
|
||||
<button
|
||||
onClick={() => navigate('/symbols')}
|
||||
className="text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
Back to Symbols
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'chart':
|
||||
return <ChartPage symbol={symbolData.symbol} showHeader={false} />;
|
||||
case 'financials':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Financial Statements</h2>
|
||||
<p className="text-text-secondary">Financial data coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'filings':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">SEC Filings</h2>
|
||||
<p className="text-text-secondary">Filings data coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'insiders':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Insider Trading</h2>
|
||||
<p className="text-text-secondary">Insider trading data coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'shorts':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Short Interest</h2>
|
||||
<p className="text-text-secondary">Short interest data coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'sentiment':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Market Sentiment</h2>
|
||||
<p className="text-text-secondary">Sentiment analysis coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'news':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Latest News</h2>
|
||||
<p className="text-text-secondary">News feed coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">Company Information</h2>
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-6">
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Symbol</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.symbol}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Name</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Exchange</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.exchange}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Country</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.country}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Type</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Currency</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.currency}</dd>
|
||||
</div>
|
||||
{symbolData.sector && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Sector</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.sector}</dd>
|
||||
</div>
|
||||
)}
|
||||
{symbolData.industry && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-secondary">Industry</dt>
|
||||
<dd className="mt-1 text-sm text-text-primary">{symbolData.industry}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with Tabs */}
|
||||
<div className="flex-shrink-0 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate('/symbols')}
|
||||
className="p-2 hover:bg-surface-tertiary transition-colors"
|
||||
aria-label="Back to symbols"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-text-secondary" />
|
||||
</button>
|
||||
<div className="px-2">
|
||||
<h1 className="text-sm font-bold text-text-primary flex items-center gap-1">
|
||||
{symbolData.symbol}
|
||||
<span className="text-xs font-normal text-text-secondary">
|
||||
{symbolData.exchange}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-text-secondary">{symbolData.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<nav className="flex ml-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
whitespace-nowrap py-2 px-3 border-b-2 font-medium text-sm
|
||||
${activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary hover:border-border'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/stock/web-app/src/features/symbols/SymbolsPage.tsx
Normal file
101
apps/stock/web-app/src/features/symbols/SymbolsPage.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { SymbolsFilter } from './components/SymbolsFilter';
|
||||
import { SymbolsTable } from './components/SymbolsTable';
|
||||
import type { Exchange, Symbol, SymbolFilter } from './types';
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockExchanges: Exchange[] = [
|
||||
{ code: 'TSX', name: 'Toronto Stock Exchange', country: 'CA' },
|
||||
{ code: 'TSXV', name: 'TSX Venture Exchange', country: 'CA' },
|
||||
{ code: 'NYSE', name: 'New York Stock Exchange', country: 'US' },
|
||||
{ code: 'NASDAQ', name: 'NASDAQ', country: 'US' },
|
||||
{ code: 'AMEX', name: 'NYSE American', country: 'US' },
|
||||
];
|
||||
|
||||
const mockSymbols: Symbol[] = [
|
||||
{ symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.48e12, sector: 'Technology', industry: 'Consumer Electronics' },
|
||||
{ symbol: 'MSFT', name: 'Microsoft Corporation', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.18e12, sector: 'Technology', industry: 'Software' },
|
||||
{ symbol: 'GOOGL', name: 'Alphabet Inc. Class A', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 2.23e12, sector: 'Technology', industry: 'Internet Services' },
|
||||
{ symbol: 'AMZN', name: 'Amazon.com Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 2.16e12, sector: 'Consumer Cyclical', industry: 'E-Commerce' },
|
||||
{ symbol: 'TSLA', name: 'Tesla Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 1.25e12, sector: 'Consumer Cyclical', industry: 'Auto Manufacturers' },
|
||||
{ symbol: 'META', name: 'Meta Platforms Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 1.52e12, sector: 'Technology', industry: 'Social Media' },
|
||||
{ symbol: 'JPM', name: 'JPMorgan Chase & Co.', exchange: 'NYSE', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 650e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'BAC', name: 'Bank of America Corp.', exchange: 'NYSE', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 370e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'TD', name: 'Toronto-Dominion Bank', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 150e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'RY', name: 'Royal Bank of Canada', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 200e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'SHOP', name: 'Shopify Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 135e9, sector: 'Technology', industry: 'E-Commerce Software' },
|
||||
{ symbol: 'CP', name: 'Canadian Pacific Kansas City', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 110e9, sector: 'Industrials', industry: 'Railroads' },
|
||||
{ symbol: 'CNR', name: 'Canadian National Railway', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 105e9, sector: 'Industrials', industry: 'Railroads' },
|
||||
{ symbol: 'BMO', name: 'Bank of Montreal', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 125e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'BNS', name: 'Bank of Nova Scotia', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 95e9, sector: 'Financial', industry: 'Banks' },
|
||||
{ symbol: 'ENB', name: 'Enbridge Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 110e9, sector: 'Energy', industry: 'Oil & Gas Pipelines' },
|
||||
{ symbol: 'SU', name: 'Suncor Energy Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 65e9, sector: 'Energy', industry: 'Oil & Gas' },
|
||||
{ symbol: 'TRP', name: 'TC Energy Corporation', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 60e9, sector: 'Energy', industry: 'Oil & Gas Pipelines' },
|
||||
{ symbol: 'MFC', name: 'Manulife Financial Corp.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 55e9, sector: 'Financial', industry: 'Insurance' },
|
||||
{ symbol: 'BAM', name: 'Brookfield Asset Management', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 85e9, sector: 'Financial', industry: 'Asset Management' },
|
||||
];
|
||||
|
||||
export function SymbolsPage() {
|
||||
const [filters, setFilters] = useState<SymbolFilter>({
|
||||
country: ['CA', 'US'],
|
||||
});
|
||||
const [filteredSymbols, setFilteredSymbols] = useState<Symbol[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
let symbols = [...mockSymbols];
|
||||
|
||||
// Apply filters
|
||||
if (filters.exchange) {
|
||||
symbols = symbols.filter(s => s.exchange === filters.exchange);
|
||||
}
|
||||
|
||||
if (filters.searchText) {
|
||||
const search = filters.searchText.toLowerCase();
|
||||
symbols = symbols.filter(s =>
|
||||
s.symbol.toLowerCase().includes(search) ||
|
||||
s.name.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.country && filters.country.length > 0) {
|
||||
symbols = symbols.filter(s => filters.country!.includes(s.country));
|
||||
}
|
||||
|
||||
// Sort by market cap (descending)
|
||||
symbols.sort((a, b) => (b.marketCap || 0) - (a.marketCap || 0));
|
||||
|
||||
setFilteredSymbols(symbols);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-4 px-3 py-2">
|
||||
<h1 className="text-lg font-bold text-text-primary">Symbols</h1>
|
||||
<SymbolsFilter
|
||||
filters={filters}
|
||||
exchanges={mockExchanges}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
<div className="text-sm text-text-secondary ml-auto">
|
||||
{isLoading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
`${filteredSymbols.length} found`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full">
|
||||
<SymbolsTable symbols={filteredSymbols} isLoading={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MultiSelect({ options, selected, onChange, placeholder = 'Select...', className = '' }: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
onChange(selected.filter(v => v !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLabels = selected.map(value =>
|
||||
options.find(opt => opt.value === value)?.label || value
|
||||
).join(', ');
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Listbox value={selected} onChange={() => {}} multiple>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative w-full cursor-default rounded-md bg-background border border-border py-1 pl-2 pr-8 text-left text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<span className={`block truncate ${selected.length === 0 ? 'text-text-secondary' : ''}`}>
|
||||
{selected.length > 0 ? selectedLabels : placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
static
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background border border-border py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={false}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-surface-secondary text-text-primary' : 'text-text-primary'
|
||||
}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggle(option.value);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<span className={`block truncate ${selected.includes(option.value) ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{selected.includes(option.value) ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-500">
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import type { Exchange, SymbolFilter } from '../types';
|
||||
import { MultiSelect } from './MultiSelect';
|
||||
|
||||
interface SymbolsFilterProps {
|
||||
filters: SymbolFilter;
|
||||
exchanges: Exchange[];
|
||||
onFiltersChange: (filters: SymbolFilter) => void;
|
||||
}
|
||||
|
||||
export function SymbolsFilter({ filters, exchanges, onFiltersChange }: SymbolsFilterProps) {
|
||||
const handleExchangeChange = (exchange: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
exchange: exchange === 'all' ? undefined : exchange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = (searchText: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
searchText: searchText.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCountryChange = (countries: string[]) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
country: countries.length > 0 ? countries : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'US', label: 'United States' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{/* Exchange Dropdown */}
|
||||
<select
|
||||
value={filters.exchange || 'all'}
|
||||
onChange={(e) => handleExchangeChange(e.target.value)}
|
||||
className="rounded-md bg-background border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="all">All Exchanges</option>
|
||||
{exchanges.map((exchange) => (
|
||||
<option key={exchange.code} value={exchange.code}>
|
||||
{exchange.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Symbol Search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<div className="absolute inset-y-0 left-0 pl-2 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-text-secondary" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={filters.searchText || ''}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full pl-8 pr-2 py-1 rounded-md bg-background border border-border text-sm text-text-primary placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country Filter */}
|
||||
<div className="w-40">
|
||||
<MultiSelect
|
||||
options={countryOptions}
|
||||
selected={filters.country || ['CA', 'US']}
|
||||
onChange={handleCountryChange}
|
||||
placeholder="Countries"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Symbol } from '../types';
|
||||
import { DataTable } from '@/components/ui/DataTable';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
interface SymbolsTableProps {
|
||||
symbols: Symbol[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SymbolsTable({ symbols, isLoading }: SymbolsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formatMarketCap = (value?: number) => {
|
||||
if (!value) return '-';
|
||||
if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
|
||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
return `$${value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Symbol>[] = [
|
||||
{
|
||||
accessorKey: 'symbol',
|
||||
header: 'Symbol',
|
||||
size: 120,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-primary">{row.original.symbol}</div>
|
||||
<div className="text-xs text-text-secondary">{row.original.currency}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
size: 250,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="text-sm text-text-primary">{getValue() as string}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'exchange',
|
||||
header: 'Exchange',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="text-sm text-text-primary">{row.original.exchange}</div>
|
||||
<div className="text-xs text-text-secondary">{row.original.country}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 text-primary-800">
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'marketCap',
|
||||
header: 'Market Cap',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm text-text-primary">{formatMarketCap(getValue() as number)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sector',
|
||||
header: 'Sector',
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="text-sm text-text-primary">{row.original.sector || '-'}</div>
|
||||
<div className="text-xs text-text-secondary">{row.original.industry || '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (symbols.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="bg-surface-secondary border-t border-border p-8">
|
||||
<div className="text-center text-text-secondary">
|
||||
No symbols found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={symbols}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
onRowClick={(symbol) => navigate(`/symbols/${symbol.symbol}`)}
|
||||
className="border-0 rounded-none border-t h-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { SymbolsFilter } from './SymbolsFilter';
|
||||
export { SymbolsTable } from './SymbolsTable';
|
||||
export { MultiSelect } from './MultiSelect';
|
||||
3
apps/stock/web-app/src/features/symbols/index.ts
Normal file
3
apps/stock/web-app/src/features/symbols/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SymbolsPage } from './SymbolsPage';
|
||||
export { SymbolDetailPage } from './SymbolDetailPage';
|
||||
export * from './types';
|
||||
23
apps/stock/web-app/src/features/symbols/types.ts
Normal file
23
apps/stock/web-app/src/features/symbols/types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface Symbol {
|
||||
symbol: string;
|
||||
name: string;
|
||||
exchange: string;
|
||||
country: string;
|
||||
type: string;
|
||||
currency: string;
|
||||
marketCap?: number;
|
||||
sector?: string;
|
||||
industry?: string;
|
||||
}
|
||||
|
||||
export interface SymbolFilter {
|
||||
exchange?: string;
|
||||
searchText?: string;
|
||||
country?: string[];
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
}
|
||||
|
|
@ -25,8 +25,8 @@ export const navigation: NavigationItem[] = [
|
|||
name: 'Market Data',
|
||||
icon: CurrencyDollarIcon,
|
||||
children: [
|
||||
{ name: 'Charts', href: '/charts', icon: ChartBarIcon },
|
||||
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
|
||||
{ name: 'Symbols', href: '/symbols', icon: ChartBarIcon },
|
||||
],
|
||||
},
|
||||
{ name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue