refactored monorepo for more projects
This commit is contained in:
parent
4632c174dc
commit
9492f1b15e
180 changed files with 1438 additions and 424 deletions
9
apps/stock/web-app/.env
Normal file
9
apps/stock/web-app/.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:4000/api
|
||||
VITE_DATA_SERVICE_URL=http://localhost:3001
|
||||
VITE_PORTFOLIO_SERVICE_URL=http://localhost:3002
|
||||
VITE_STRATEGY_SERVICE_URL=http://localhost:3003
|
||||
VITE_EXECUTION_SERVICE_URL=http://localhost:3004
|
||||
|
||||
# Environment
|
||||
VITE_NODE_ENV=development
|
||||
9
apps/stock/web-app/.env.example
Normal file
9
apps/stock/web-app/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_DATA_SERVICE_URL=http://localhost:3001
|
||||
VITE_PORTFOLIO_SERVICE_URL=http://localhost:3002
|
||||
VITE_STRATEGY_SERVICE_URL=http://localhost:3003
|
||||
VITE_EXECUTION_SERVICE_URL=http://localhost:3004
|
||||
|
||||
# Environment
|
||||
VITE_NODE_ENV=development
|
||||
95
apps/stock/web-app/README.md
Normal file
95
apps/stock/web-app/README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Stock Bot Web Dashboard
|
||||
|
||||
A modern React web dashboard for the Stock Bot trading system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern UI**: Built with React 18 and TypeScript
|
||||
- **Tailwind CSS**: For beautiful, responsive styling
|
||||
- **Headless UI**: Accessible components for dialogs and interactions
|
||||
- **Heroicons**: Beautiful SVG icons
|
||||
- **Responsive Design**: Works on desktop and mobile
|
||||
- **Side Navigation**: Clean navigation with mobile-responsive sidebar
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 18** with TypeScript
|
||||
- **Vite** for fast development and building
|
||||
- **Tailwind CSS** for styling
|
||||
- **Headless UI** for accessible components
|
||||
- **Heroicons** for icons
|
||||
- **Bun** for package management
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun (for package management)
|
||||
- Node.js 18+
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Start development server
|
||||
bun run dev
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Preview production build
|
||||
bun run preview
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `bun run dev` - Start development server
|
||||
- `bun run build` - Build for production
|
||||
- `bun run preview` - Preview production build
|
||||
- `bun run lint` - Run ESLint
|
||||
- `bun run type-check` - Run TypeScript type checking
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx # Main app component with sidebar navigation
|
||||
├── main.tsx # React app entry point
|
||||
└── index.css # Global styles with Tailwind directives
|
||||
```
|
||||
|
||||
## Dashboard Features
|
||||
|
||||
The dashboard includes:
|
||||
|
||||
- **Responsive Sidebar**: Navigation that works on desktop and mobile
|
||||
- **Dashboard Overview**: Portfolio value, returns, and active strategies
|
||||
- **Navigation Sections**:
|
||||
- Dashboard (current page)
|
||||
- Portfolio
|
||||
- Analytics
|
||||
- Reports
|
||||
- Settings
|
||||
|
||||
## Development
|
||||
|
||||
The app is set up with:
|
||||
|
||||
- TypeScript for type safety
|
||||
- ESLint for code quality
|
||||
- Tailwind CSS for utility-first styling
|
||||
- Headless UI for accessible components
|
||||
- Hot module replacement for fast development
|
||||
|
||||
## Integration
|
||||
|
||||
This web app is part of the larger Stock Bot system and will integrate with:
|
||||
|
||||
- Data Service (for market data)
|
||||
- Portfolio Service (for portfolio management)
|
||||
- Strategy Service (for trading strategies)
|
||||
- Execution Service (for trade execution)
|
||||
|
||||
The web dashboard will communicate with these services via REST APIs to provide a complete trading dashboard experience.
|
||||
58
apps/stock/web-app/eslint.config.js
Normal file
58
apps/stock/web-app/eslint.config.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import js from '@eslint/js';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
React: 'readonly',
|
||||
},
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'react': react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...tseslint.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// React configuration
|
||||
'react/react-in-jsx-scope': 'off', // Not needed with React 17+
|
||||
'react/jsx-uses-react': 'off', // Not needed with React 17+
|
||||
|
||||
// TypeScript specific
|
||||
'@typescript-eslint/no-explicit-any': 'warn', // Allow any but warn
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// General ESLint rules
|
||||
'no-undef': 'off', // TypeScript handles this
|
||||
'no-console': 'warn',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
15
apps/stock/web-app/index.html
Normal file
15
apps/stock/web-app/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Stock Bot - Advanced Trading Dashboard" />
|
||||
<title>Stock Bot - Trading Dashboard</title>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
apps/stock/web-app/package.json
Normal file
45
apps/stock/web-app/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@stock-bot/web-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-virtuoso": "^4.12.8",
|
||||
"react-window": "^1.8.11",
|
||||
"react-window-infinite-loader": "^1.0.10",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
apps/stock/web-app/postcss.config.js
Normal file
6
apps/stock/web-app/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
apps/stock/web-app/public/vite.svg
Normal file
4
apps/stock/web-app/public/vite.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
<circle cx="12" cy="12" r="10" stroke="#1e40af" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 314 B |
232
apps/stock/web-app/src/App.tsx
Normal file
232
apps/stock/web-app/src/App.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useState } from 'react';
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import {
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
HomeIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon,
|
||||
DocumentTextIcon,
|
||||
CurrencyDollarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '#', icon: HomeIcon, current: true },
|
||||
{ name: 'Portfolio', href: '#', icon: CurrencyDollarIcon, current: false },
|
||||
{ name: 'Analytics', href: '#', icon: ChartBarIcon, current: false },
|
||||
{ name: 'Reports', href: '#', icon: DocumentTextIcon, current: false },
|
||||
{ name: 'Settings', href: '#', icon: CogIcon, current: false },
|
||||
];
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50 lg:hidden"
|
||||
open={sidebarOpen}
|
||||
onClose={setSidebarOpen}
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/80" />
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<Dialog.Panel className="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon className="h-6 w-6 text-text-primary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-y-3 overflow-y-auto bg-background px-3 pb-2 scrollbar-thin">
|
||||
<div className="flex h-12 shrink-0 items-center border-b border-border">
|
||||
<h1 className="text-lg font-bold text-text-primary">Stock Bot</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-4">
|
||||
<li>
|
||||
<ul role="list" className="-mx-1 space-y-0.5">
|
||||
{navigation.map(item => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
||||
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-4 w-4 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-60 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-3 overflow-y-auto border-r border-border bg-background px-3 scrollbar-thin">
|
||||
<div className="flex h-12 shrink-0 items-center border-b border-border">
|
||||
<h1 className="text-lg font-bold text-text-primary">Stock Bot</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-4">
|
||||
<li>
|
||||
<ul role="list" className="-mx-1 space-y-0.5">
|
||||
{navigation.map(item => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
||||
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-4 w-4 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-4 bg-background px-3 py-2 shadow-sm sm:px-4 lg:hidden border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-1.5 p-1.5 text-text-secondary lg:hidden hover:text-text-primary transition-colors"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="flex-1 text-sm font-medium leading-tight text-text-primary">
|
||||
Dashboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-4 lg:pl-60 w-full">
|
||||
<div className="px-4">
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2">
|
||||
Welcome to Stock Bot Dashboard
|
||||
</h1>
|
||||
<p className="text-text-secondary mb-6 text-sm">
|
||||
Monitor your trading performance, manage portfolios, and analyze market data.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border hover:border-primary-500/50 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<div className="p-1.5 bg-primary-500/10 rounded">
|
||||
<CurrencyDollarIcon className="h-5 w-5 text-primary-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">Portfolio Value</h3>
|
||||
<p className="text-lg font-bold text-primary-400">$0.00</p>
|
||||
<p className="text-xs text-text-muted">Total assets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border hover:border-success/50 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<div className="p-1.5 bg-success/10 rounded">
|
||||
<ChartBarIcon className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">Total Return</h3>
|
||||
<p className="text-lg font-bold text-success">+0.00%</p>
|
||||
<p className="text-xs text-text-muted">Since inception</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border hover:border-warning/50 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<div className="p-1.5 bg-warning/10 rounded">
|
||||
<DocumentTextIcon className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
Active Strategies
|
||||
</h3>
|
||||
<p className="text-lg font-bold text-warning">0</p>
|
||||
<p className="text-xs text-text-muted">Running algorithms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||
<h3 className="text-base font-medium text-text-primary mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
|
||||
<span className="text-text-secondary text-sm">No recent activity</span>
|
||||
<span className="text-text-muted text-xs">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||
<h3 className="text-base font-medium text-text-primary mb-3">
|
||||
Market Overview
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
|
||||
<span className="text-text-secondary text-sm">
|
||||
Market data loading...
|
||||
</span>
|
||||
<span className="text-text-muted text-xs">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/stock/web-app/src/app/App.tsx
Normal file
31
apps/stock/web-app/src/app/App.tsx
Normal file
|
|
@ -0,0 +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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="exchanges" element={<ExchangesPage />} />
|
||||
<Route
|
||||
path="portfolio"
|
||||
element={<div className="p-4">Portfolio Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route
|
||||
path="strategies"
|
||||
element={<div className="p-4">Strategies Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route
|
||||
path="analytics"
|
||||
element={<div className="p-4">Analytics Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
1
apps/stock/web-app/src/app/index.ts
Normal file
1
apps/stock/web-app/src/app/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { App } from './App';
|
||||
2
apps/stock/web-app/src/components/index.ts
Normal file
2
apps/stock/web-app/src/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ui';
|
||||
export * from './layout';
|
||||
22
apps/stock/web-app/src/components/layout/Header.tsx
Normal file
22
apps/stock/web-app/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface HeaderProps {
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Header({ setSidebarOpen, title = 'Dashboard' }: HeaderProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-4 bg-background px-3 py-2 shadow-sm sm:px-4 lg:hidden border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-1.5 p-1.5 text-text-secondary lg:hidden hover:text-text-primary transition-colors"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="flex-1 text-sm font-medium leading-tight text-text-primary">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/stock/web-app/src/components/layout/Layout.tsx
Normal file
29
apps/stock/web-app/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Determine title from current route
|
||||
const getTitle = () => {
|
||||
const path = location.pathname.replace('/', '');
|
||||
if (!path || path === 'dashboard') {return 'Dashboard';}
|
||||
return path.charAt(0).toUpperCase() + path.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
<Header setSidebarOpen={setSidebarOpen} title={getTitle()} />
|
||||
|
||||
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto">
|
||||
<div className="px-4 flex-col h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/stock/web-app/src/components/layout/Sidebar.tsx
Normal file
124
apps/stock/web-app/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { navigation } from '@/lib/constants';
|
||||
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;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile sidebar */}
|
||||
<Transition.Root show={sidebarOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50 lg:hidden" onClose={setSidebarOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/80" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon className="h-6 w-6 text-text-primary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
|
||||
<SidebarContent />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-60 lg:flex-col">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent() {
|
||||
return (
|
||||
<div className="flex grow flex-col gap-y-3 overflow-y-auto border-r border-border bg-background px-3">
|
||||
<div className="flex h-12 shrink-0 items-center border-b border-border">
|
||||
<h1 className="text-lg font-bold text-text-primary">Stock Bot</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-4">
|
||||
<li>
|
||||
<ul role="list" className="-mx-1 space-y-0.5">
|
||||
{navigation.map(item => (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
isActive
|
||||
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
|
||||
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
|
||||
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon
|
||||
className={cn(
|
||||
isActive
|
||||
? 'text-primary-400'
|
||||
: 'text-text-muted group-hover:text-primary-400',
|
||||
'h-4 w-4 shrink-0 transition-colors'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/stock/web-app/src/components/layout/index.ts
Normal file
3
apps/stock/web-app/src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Layout } from './Layout';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
40
apps/stock/web-app/src/components/ui/Card.tsx
Normal file
40
apps/stock/web-app/src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function Card({ children, className, hover = false }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-secondary rounded-lg border border-border p-4',
|
||||
hover && 'hover:border-primary-500/50 transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className }: CardHeaderProps) {
|
||||
return <div className={cn('flex items-center mb-3', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface CardContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className }: CardContentProps) {
|
||||
return <div className={cn('space-y-2', className)}>{children}</div>;
|
||||
}
|
||||
242
apps/stock/web-app/src/components/ui/DataTable/DataTable.tsx
Normal file
242
apps/stock/web-app/src/components/ui/DataTable/DataTable.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useState, useRef } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
|
||||
// Tooltip wrapper for cells that might overflow
|
||||
function CellWithTooltip({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipContent, setTooltipContent] = useState('');
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const element = cellRef.current;
|
||||
if (element) {
|
||||
// Get the text content from the element or its children
|
||||
const textContent = element.textContent || '';
|
||||
setTooltipContent(textContent);
|
||||
|
||||
// Check if content is overflowing by comparing scroll width to client width
|
||||
const isOverflowing = element.scrollWidth > element.clientWidth;
|
||||
if (isOverflowing && textContent.trim().length > 0) {
|
||||
setShowTooltip(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={cellRef}
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showTooltip && tooltipContent && (
|
||||
<div className="absolute z-50 px-2 py-1 bg-surface-secondary border border-border text-text-primary text-xs rounded shadow-lg whitespace-nowrap -top-8 left-0 pointer-events-none">
|
||||
{tooltipContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: ColumnDef<T>[];
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
getRowCanExpand?: (row: Row<T>) => boolean;
|
||||
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
|
||||
onRowClick?: (row: T) => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
className = '',
|
||||
getRowCanExpand,
|
||||
renderSubComponent,
|
||||
onRowClick,
|
||||
height,
|
||||
}: DataTableProps<T>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowCanExpand,
|
||||
enableColumnResizing: true,
|
||||
columnResizeMode: 'onChange',
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-background border border-border rounded-lg">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
// For expanded rows, we need to create a flattened list
|
||||
const flatRows = rows.reduce<Array<{ type: 'row' | 'expanded'; row: Row<T> }>>((acc, row) => {
|
||||
acc.push({ type: 'row', row });
|
||||
if (row.getIsExpanded() && renderSubComponent) {
|
||||
acc.push({ type: 'expanded', row });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TableVirtuoso
|
||||
style={{ height: height ? `${height}px` : '100%' }}
|
||||
className={cn('border border-border rounded-lg', className)}
|
||||
totalCount={flatRows.length}
|
||||
components={{
|
||||
Table: ({ style, ...props }) => (
|
||||
<table
|
||||
{...props}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
tableLayout: 'fixed',
|
||||
}}
|
||||
className="bg-background"
|
||||
/>
|
||||
),
|
||||
TableRow: props => {
|
||||
const index = props['data-index'] as number;
|
||||
const item = flatRows[index];
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.type === 'expanded') {
|
||||
return (
|
||||
<tr {...props} className="bg-surface-secondary/50">
|
||||
<td colSpan={item.row.getVisibleCells().length} className="p-0">
|
||||
{renderSubComponent?.({ row: item.row })}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...props}
|
||||
className="hover:bg-surface border-b border-border cursor-pointer"
|
||||
onClick={() => onRowClick?.(item.row.original)}
|
||||
>
|
||||
{item.row.getVisibleCells().map(cell => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className="px-3 py-2 text-sm text-text-primary"
|
||||
style={{
|
||||
width: `${cell.column.getSize()}px`,
|
||||
minWidth: `${cell.column.getSize()}px`,
|
||||
maxWidth: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
>
|
||||
{(cell.column.columnDef as { disableTooltip?: boolean }).disableTooltip ? (
|
||||
<div className="truncate overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
) : (
|
||||
<CellWithTooltip className="truncate overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</CellWithTooltip>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
}}
|
||||
fixedHeaderContent={() =>
|
||||
table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id} className="bg-surface border-b border-border">
|
||||
{headerGroup.headers.map(header => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'relative px-3 py-2 text-xs font-medium text-text-secondary uppercase tracking-wider text-left',
|
||||
header.column.getCanSort() && 'cursor-pointer select-none hover:text-text-primary'
|
||||
)}
|
||||
style={{
|
||||
width: `${header.getSize()}px`,
|
||||
minWidth: `${header.getSize()}px`,
|
||||
maxWidth: `${header.getSize()}px`,
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{header.column.getCanSort() && (
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
{header.column.getIsSorted() === 'asc' ? (
|
||||
<ChevronUpIcon className="h-4 w-4 text-primary-400" />
|
||||
) : header.column.getIsSorted() === 'desc' ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-primary-400" />
|
||||
) : (
|
||||
<div className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Column resizer */}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
'absolute right-0 top-0 h-full w-1 cursor-col-resize user-select-none touch-none',
|
||||
'hover:bg-primary-500 hover:opacity-100',
|
||||
header.column.getIsResizing()
|
||||
? 'bg-primary-500 opacity-100'
|
||||
: 'bg-border opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
apps/stock/web-app/src/components/ui/DataTable/index.ts
Normal file
1
apps/stock/web-app/src/components/ui/DataTable/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DataTable } from './DataTable';
|
||||
66
apps/stock/web-app/src/components/ui/DataTable/types.ts
Normal file
66
apps/stock/web-app/src/components/ui/DataTable/types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
export interface TableColumn<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
header: string;
|
||||
accessorKey?: keyof T;
|
||||
accessorFn?: (row: T) => unknown;
|
||||
cell?: (props: { getValue: () => unknown; row: { original: T } }) => ReactNode;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
groupable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
enableResizing?: boolean;
|
||||
}
|
||||
|
||||
export interface TableConfig {
|
||||
enableSorting?: boolean;
|
||||
enableFiltering?: boolean;
|
||||
enableGrouping?: boolean;
|
||||
enableColumnResizing?: boolean;
|
||||
enableRowSelection?: boolean;
|
||||
manualPagination?: boolean;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface VirtualTableProps<T> {
|
||||
data: T[];
|
||||
columns: TableColumn<T>[];
|
||||
height?: number;
|
||||
width?: number;
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
config?: TableConfig;
|
||||
loading?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FilterCondition {
|
||||
column: string;
|
||||
operator:
|
||||
| 'equals'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'gte'
|
||||
| 'lte'
|
||||
| 'between'
|
||||
| 'in';
|
||||
value: string | number | boolean | null;
|
||||
value2?: string | number | boolean | null; // For 'between' operator
|
||||
}
|
||||
|
||||
export interface SortingState {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
}
|
||||
|
||||
export interface GroupingState {
|
||||
groupBy: string[];
|
||||
aggregations?: Record<string, 'sum' | 'avg' | 'count' | 'min' | 'max'>;
|
||||
}
|
||||
35
apps/stock/web-app/src/components/ui/StatCard.tsx
Normal file
35
apps/stock/web-app/src/components/ui/StatCard.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: ReactNode;
|
||||
iconBgColor: string;
|
||||
valueColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
iconBgColor,
|
||||
valueColor,
|
||||
borderColor,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card hover className={`hover:${borderColor}`}>
|
||||
<div className="flex items-center">
|
||||
<div className={`p-1.5 ${iconBgColor} rounded`}>{icon}</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||
<p className={`text-lg font-bold ${valueColor}`}>{value}</p>
|
||||
{subtitle && <p className="text-xs text-text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
apps/stock/web-app/src/components/ui/button.tsx
Normal file
38
apps/stock/web-app/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'outline' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className = '',
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500',
|
||||
outline: 'border border-border bg-transparent text-text-primary hover:bg-surface-secondary focus:ring-primary-500',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<button className={classes} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
58
apps/stock/web-app/src/components/ui/dialog.tsx
Normal file
58
apps/stock/web-app/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||
if (!open) {return null;}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div className="relative z-50 max-h-[90vh] overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DialogContent({ children, className = '' }: DialogContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-surface border border-border rounded-lg shadow-lg p-6 w-full ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DialogHeader({ children, className = '' }: DialogHeaderProps) {
|
||||
return <div className={`mb-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
interface DialogTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DialogTitle({ children, className = '' }: DialogTitleProps) {
|
||||
return <h2 className={`text-lg font-semibold text-text-primary ${className}`}>{children}</h2>;
|
||||
}
|
||||
5
apps/stock/web-app/src/components/ui/index.ts
Normal file
5
apps/stock/web-app/src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { Card, CardHeader, CardContent } from './Card';
|
||||
export { StatCard } from './StatCard';
|
||||
export { DataTable } from './DataTable';
|
||||
export { Dialog, DialogContent, DialogHeader, DialogTitle } from './dialog';
|
||||
export { Button } from './button';
|
||||
20
apps/stock/web-app/src/features/dashboard/DashboardPage.tsx
Normal file
20
apps/stock/web-app/src/features/dashboard/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { DashboardActivity, DashboardStats, PortfolioTable } from './components';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2">Welcome to Stock Bot Dashboard</h1>
|
||||
<p className="text-text-secondary mb-6 text-sm">
|
||||
Monitor your trading performance, manage portfolios, and analyze market data.
|
||||
</p>
|
||||
<DashboardStats />
|
||||
<DashboardActivity />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<PortfolioTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Card, CardHeader, CardContent } from '@/components/ui';
|
||||
|
||||
export function DashboardActivity() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-base font-medium text-text-primary">Recent Activity</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
|
||||
<span className="text-text-secondary text-sm">No recent activity</span>
|
||||
<span className="text-text-muted text-xs">--</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-base font-medium text-text-primary">Market Overview</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-2 bg-surface-tertiary rounded border border-border">
|
||||
<span className="text-text-secondary text-sm">Market data loading...</span>
|
||||
<span className="text-text-muted text-xs">--</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { CurrencyDollarIcon, ChartBarIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import { StatCard } from '@/components/ui';
|
||||
|
||||
export function DashboardStats() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Portfolio Value"
|
||||
value="$0.00"
|
||||
subtitle="Total assets"
|
||||
icon={<CurrencyDollarIcon className="h-5 w-5 text-primary-400" />}
|
||||
iconBgColor="bg-primary-500/10"
|
||||
valueColor="text-primary-400"
|
||||
borderColor="border-primary-500/50"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Total Return"
|
||||
value="+0.00%"
|
||||
subtitle="Since inception"
|
||||
icon={<ChartBarIcon className="h-5 w-5 text-success" />}
|
||||
iconBgColor="bg-success/10"
|
||||
valueColor="text-success"
|
||||
borderColor="border-success/50"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Active Strategies"
|
||||
value="0"
|
||||
subtitle="Running algorithms"
|
||||
icon={<DocumentTextIcon className="h-5 w-5 text-warning" />}
|
||||
iconBgColor="bg-warning/10"
|
||||
valueColor="text-warning"
|
||||
borderColor="border-warning/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
import { DataTable } from '@/components/ui';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import React from 'react';
|
||||
|
||||
interface PortfolioItem {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
avgPrice: number;
|
||||
currentPrice: number;
|
||||
value: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
// Additional columns for stress testing
|
||||
volume: number;
|
||||
marketCap: number;
|
||||
pe: number;
|
||||
pb: number;
|
||||
roe: number;
|
||||
debt: number;
|
||||
revenue: number;
|
||||
earnings: number;
|
||||
dividend: number;
|
||||
beta: number;
|
||||
rsi: number;
|
||||
macd: number;
|
||||
sma20: number;
|
||||
sma50: number;
|
||||
sma200: number;
|
||||
support: number;
|
||||
resistance: number;
|
||||
volatility: number;
|
||||
sharpe: number;
|
||||
alpha: number;
|
||||
correlation: number;
|
||||
sector: string;
|
||||
industry: string;
|
||||
country: string;
|
||||
exchange: string;
|
||||
currency: string;
|
||||
lastUpdate: string;
|
||||
analyst1: string;
|
||||
analyst2: string;
|
||||
analyst3: string;
|
||||
rating1: number;
|
||||
rating2: number;
|
||||
rating3: number;
|
||||
target1: number;
|
||||
target2: number;
|
||||
target3: number;
|
||||
risk: string;
|
||||
esg: number;
|
||||
}
|
||||
|
||||
export function PortfolioTable() {
|
||||
// Generate 100,000 rows of sample data
|
||||
const data: PortfolioItem[] = React.useMemo(() => {
|
||||
const symbols = [
|
||||
'AAPL',
|
||||
'GOOGL',
|
||||
'MSFT',
|
||||
'TSLA',
|
||||
'AMZN',
|
||||
'META',
|
||||
'NFLX',
|
||||
'NVDA',
|
||||
'AMD',
|
||||
'INTC',
|
||||
'CRM',
|
||||
'ORCL',
|
||||
'IBM',
|
||||
'CSCO',
|
||||
'UBER',
|
||||
'LYFT',
|
||||
'SNAP',
|
||||
'TWTR',
|
||||
'SPOT',
|
||||
'SQ',
|
||||
];
|
||||
const sectors = [
|
||||
'Technology',
|
||||
'Healthcare',
|
||||
'Finance',
|
||||
'Energy',
|
||||
'Consumer',
|
||||
'Industrial',
|
||||
'Materials',
|
||||
'Utilities',
|
||||
'Real Estate',
|
||||
'Telecom',
|
||||
];
|
||||
const industries = [
|
||||
'Software',
|
||||
'Hardware',
|
||||
'Biotech',
|
||||
'Banking',
|
||||
'Oil & Gas',
|
||||
'Retail',
|
||||
'Manufacturing',
|
||||
'Mining',
|
||||
'Utilities',
|
||||
'REITs',
|
||||
];
|
||||
const countries = [
|
||||
'USA',
|
||||
'Canada',
|
||||
'UK',
|
||||
'Germany',
|
||||
'Japan',
|
||||
'China',
|
||||
'India',
|
||||
'Brazil',
|
||||
'Australia',
|
||||
'France',
|
||||
];
|
||||
const exchanges = ['NYSE', 'NASDAQ', 'LSE', 'TSX', 'Nikkei', 'SSE', 'BSE', 'ASX', 'Euronext'];
|
||||
const currencies = ['USD', 'CAD', 'GBP', 'EUR', 'JPY', 'CNY', 'INR', 'BRL', 'AUD'];
|
||||
const analysts = [
|
||||
'Goldman Sachs',
|
||||
'Morgan Stanley',
|
||||
'JPMorgan',
|
||||
'Bank of America',
|
||||
'Wells Fargo',
|
||||
'Credit Suisse',
|
||||
'Deutsche Bank',
|
||||
'Barclays',
|
||||
'UBS',
|
||||
'Citigroup',
|
||||
];
|
||||
const risks = ['Low', 'Medium', 'High', 'Very High'];
|
||||
|
||||
return Array.from({ length: 100000 }, (_, i) => {
|
||||
const basePrice = 50 + Math.random() * 500;
|
||||
const change = (Math.random() - 0.5) * 20;
|
||||
const quantity = Math.floor(Math.random() * 1000) + 1;
|
||||
|
||||
return {
|
||||
symbol: `${symbols[i % symbols.length]}${Math.floor(i / symbols.length)}`,
|
||||
quantity,
|
||||
avgPrice: basePrice,
|
||||
currentPrice: basePrice + change,
|
||||
value: (basePrice + change) * quantity,
|
||||
change: change * quantity,
|
||||
changePercent: (change / basePrice) * 100,
|
||||
volume: Math.floor(Math.random() * 10000000),
|
||||
marketCap: Math.floor(Math.random() * 1000000000000),
|
||||
pe: Math.random() * 50 + 5,
|
||||
pb: Math.random() * 10 + 0.5,
|
||||
roe: Math.random() * 30 + 5,
|
||||
debt: Math.random() * 50,
|
||||
revenue: Math.floor(Math.random() * 100000000000),
|
||||
earnings: Math.floor(Math.random() * 10000000000),
|
||||
dividend: Math.random() * 5,
|
||||
beta: Math.random() * 3 + 0.5,
|
||||
rsi: Math.random() * 100,
|
||||
macd: (Math.random() - 0.5) * 10,
|
||||
sma20: basePrice + (Math.random() - 0.5) * 10,
|
||||
sma50: basePrice + (Math.random() - 0.5) * 20,
|
||||
sma200: basePrice + (Math.random() - 0.5) * 50,
|
||||
support: basePrice - Math.random() * 20,
|
||||
resistance: basePrice + Math.random() * 20,
|
||||
volatility: Math.random() * 100,
|
||||
sharpe: Math.random() * 3,
|
||||
alpha: (Math.random() - 0.5) * 20,
|
||||
correlation: (Math.random() - 0.5) * 2,
|
||||
sector: sectors[Math.floor(Math.random() * sectors.length)],
|
||||
industry: industries[Math.floor(Math.random() * industries.length)],
|
||||
country: countries[Math.floor(Math.random() * countries.length)],
|
||||
exchange: exchanges[Math.floor(Math.random() * exchanges.length)],
|
||||
currency: currencies[Math.floor(Math.random() * currencies.length)],
|
||||
lastUpdate: new Date(Date.now() - Math.random() * 86400000).toISOString(),
|
||||
analyst1: analysts[Math.floor(Math.random() * analysts.length)],
|
||||
analyst2: analysts[Math.floor(Math.random() * analysts.length)],
|
||||
analyst3: analysts[Math.floor(Math.random() * analysts.length)],
|
||||
rating1: Math.random() * 5 + 1,
|
||||
rating2: Math.random() * 5 + 1,
|
||||
rating3: Math.random() * 5 + 1,
|
||||
target1: basePrice + (Math.random() - 0.3) * 50,
|
||||
target2: basePrice + (Math.random() - 0.3) * 50,
|
||||
target3: basePrice + (Math.random() - 0.3) * 50,
|
||||
risk: risks[Math.floor(Math.random() * risks.length)],
|
||||
esg: Math.random() * 100,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns: ColumnDef<PortfolioItem>[] = [
|
||||
{
|
||||
id: 'symbol',
|
||||
header: 'Symbol',
|
||||
accessorKey: 'symbol',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono font-bold text-primary-400">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
header: 'Quantity',
|
||||
accessorKey: 'quantity',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'avgPrice',
|
||||
header: 'Avg Price',
|
||||
accessorKey: 'avgPrice',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currentPrice',
|
||||
header: 'Current Price',
|
||||
accessorKey: 'currentPrice',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
accessorKey: 'value',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono font-bold">${(getValue() as number).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'change',
|
||||
header: 'P&L',
|
||||
accessorKey: 'change',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<span className={`font-mono font-medium ${isPositive ? 'text-success' : 'text-danger'}`}>
|
||||
{isPositive ? '+' : ''}${value.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'changePercent',
|
||||
header: 'P&L %',
|
||||
accessorKey: 'changePercent',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<span className={`font-mono font-medium ${isPositive ? 'text-success' : 'text-danger'}`}>
|
||||
{isPositive ? '+' : ''}
|
||||
{value.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'volume',
|
||||
header: 'Volume',
|
||||
accessorKey: 'volume',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-text-secondary">
|
||||
{(getValue() as number).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'marketCap',
|
||||
header: 'Market Cap',
|
||||
accessorKey: 'marketCap',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
if (value >= 1e12) {return <span className="font-mono">${(value / 1e12).toFixed(2)}T</span>;}
|
||||
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
|
||||
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
|
||||
return <span className="font-mono">${value.toLocaleString()}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pe',
|
||||
header: 'P/E',
|
||||
accessorKey: 'pe',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pb',
|
||||
header: 'P/B',
|
||||
accessorKey: 'pb',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'roe',
|
||||
header: 'ROE %',
|
||||
accessorKey: 'roe',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'debt',
|
||||
header: 'Debt %',
|
||||
accessorKey: 'debt',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'revenue',
|
||||
header: 'Revenue',
|
||||
accessorKey: 'revenue',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
|
||||
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
|
||||
return <span className="font-mono">${value.toLocaleString()}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earnings',
|
||||
header: 'Earnings',
|
||||
accessorKey: 'earnings',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
if (value >= 1e9) {return <span className="font-mono">${(value / 1e9).toFixed(2)}B</span>;}
|
||||
if (value >= 1e6) {return <span className="font-mono">${(value / 1e6).toFixed(2)}M</span>;}
|
||||
return <span className="font-mono">${value.toLocaleString()}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dividend',
|
||||
header: 'Dividend %',
|
||||
accessorKey: 'dividend',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
return <span className="font-mono text-success">{value.toFixed(2)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'beta',
|
||||
header: 'Beta',
|
||||
accessorKey: 'beta',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value > 1 ? 'text-warning' : value < 1 ? 'text-success' : 'text-text-primary';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rsi',
|
||||
header: 'RSI',
|
||||
accessorKey: 'rsi',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color =
|
||||
value > 70 ? 'text-danger' : value < 30 ? 'text-success' : 'text-text-primary';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'macd',
|
||||
header: 'MACD',
|
||||
accessorKey: 'macd',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value > 0 ? 'text-success' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sma20',
|
||||
header: 'SMA 20',
|
||||
accessorKey: 'sma20',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sma50',
|
||||
header: 'SMA 50',
|
||||
accessorKey: 'sma50',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sma200',
|
||||
header: 'SMA 200',
|
||||
accessorKey: 'sma200',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
header: 'Support',
|
||||
accessorKey: 'support',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-success">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'resistance',
|
||||
header: 'Resistance',
|
||||
accessorKey: 'resistance',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-danger">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'volatility',
|
||||
header: 'Volatility',
|
||||
accessorKey: 'volatility',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(1)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sharpe',
|
||||
header: 'Sharpe',
|
||||
accessorKey: 'sharpe',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'alpha',
|
||||
header: 'Alpha',
|
||||
accessorKey: 'alpha',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value > 0 ? 'text-success' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'correlation',
|
||||
header: 'Correlation',
|
||||
accessorKey: 'correlation',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">{(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sector',
|
||||
header: 'Sector',
|
||||
accessorKey: 'sector',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: 'industry',
|
||||
header: 'Industry',
|
||||
accessorKey: 'industry',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
header: 'Country',
|
||||
accessorKey: 'country',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: 'exchange',
|
||||
header: 'Exchange',
|
||||
accessorKey: 'exchange',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => <span className="text-text-secondary">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: 'currency',
|
||||
header: 'Currency',
|
||||
accessorKey: 'currency',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-text-secondary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastUpdate',
|
||||
header: 'Last Update',
|
||||
accessorKey: 'lastUpdate',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-muted">
|
||||
{new Date(getValue() as string).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'analyst1',
|
||||
header: 'Analyst 1',
|
||||
accessorKey: 'analyst1',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-secondary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'analyst2',
|
||||
header: 'Analyst 2',
|
||||
accessorKey: 'analyst2',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-secondary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'analyst3',
|
||||
header: 'Analyst 3',
|
||||
accessorKey: 'analyst3',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-secondary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'rating1',
|
||||
header: 'Rating 1',
|
||||
accessorKey: 'rating1',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rating2',
|
||||
header: 'Rating 2',
|
||||
accessorKey: 'rating2',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rating3',
|
||||
header: 'Rating 3',
|
||||
accessorKey: 'rating3',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value >= 4 ? 'text-success' : value >= 3 ? 'text-warning' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(1)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'target1',
|
||||
header: 'Target 1',
|
||||
accessorKey: 'target1',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'target2',
|
||||
header: 'Target 2',
|
||||
accessorKey: 'target2',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'target3',
|
||||
header: 'Target 3',
|
||||
accessorKey: 'target3',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'risk',
|
||||
header: 'Risk Level',
|
||||
accessorKey: 'risk',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as string;
|
||||
const color =
|
||||
value === 'Low' ? 'text-success' : value === 'Medium' ? 'text-warning' : 'text-danger';
|
||||
return <span className={`px-2 py-1 rounded text-xs font-medium ${color}`}>{value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'esg',
|
||||
header: 'ESG Score',
|
||||
accessorKey: 'esg',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as number;
|
||||
const color = value >= 70 ? 'text-success' : value >= 40 ? 'text-warning' : 'text-danger';
|
||||
return <span className={`font-mono ${color}`}>{value.toFixed(0)}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
onRowClick={_row => {/* Handle row click */}}
|
||||
className="border border-border rounded-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { DashboardStats } from './DashboardStats';
|
||||
export { DashboardActivity } from './DashboardActivity';
|
||||
export { PortfolioTable } from './PortfolioTable';
|
||||
1
apps/stock/web-app/src/features/dashboard/index.ts
Normal file
1
apps/stock/web-app/src/features/dashboard/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DashboardPage } from './DashboardPage';
|
||||
147
apps/stock/web-app/src/features/exchanges/ExchangesPage.tsx
Normal file
147
apps/stock/web-app/src/features/exchanges/ExchangesPage.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExchangesTable } from './components/ExchangesTable';
|
||||
import { AddExchangeDialog } from './components/AddExchangeDialog';
|
||||
import { useExchanges } from './hooks/useExchanges';
|
||||
|
||||
export function ExchangesPage() {
|
||||
const { createExchange } = useExchanges();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
type: 'success' | 'error' | null;
|
||||
message: string;
|
||||
}>({ type: null, message: '' });
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
|
||||
// Auto-dismiss success messages after 5 seconds
|
||||
useEffect(() => {
|
||||
if (syncStatus.type === 'success') {
|
||||
const timer = setTimeout(() => {
|
||||
setSyncStatus({ type: null, message: '' });
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [syncStatus.type]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncStatus({ type: null, message: '' });
|
||||
|
||||
try {
|
||||
// TODO: Implement sync functionality
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate sync
|
||||
setSyncStatus({
|
||||
type: 'success',
|
||||
message: 'Exchange sync functionality coming soon!',
|
||||
});
|
||||
} catch (error) {
|
||||
setSyncStatus({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Sync failed with unknown error',
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2">Exchange Management</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Configure and manage master exchanges with their data sources and providers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Exchange
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
{syncing ? 'Syncing...' : 'Sync Exchanges'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncing && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowPathIcon className="h-4 w-4 text-primary-500 animate-spin" />
|
||||
<span className="text-primary-700 text-sm">
|
||||
Syncing exchanges from Interactive Brokers data...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncStatus.type && (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm">
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-4 shadow-xl transform transition-all duration-300 ease-out">
|
||||
<div className="flex items-start gap-3">
|
||||
{syncStatus.type === 'success' ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-danger flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
syncStatus.type === 'success' ? 'text-success' : 'text-danger'
|
||||
}`}
|
||||
>
|
||||
{syncStatus.type === 'success' ? 'Sync Completed' : 'Sync Failed'}
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-text-secondary">{syncStatus.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSyncStatus({ type: null, message: '' })}
|
||||
className="flex-shrink-0 p-1 rounded-full text-text-muted hover:text-text-primary hover:bg-surface transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ExchangesTable />
|
||||
</div>
|
||||
|
||||
<AddExchangeDialog
|
||||
isOpen={showAddDialog}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
onCreateExchange={async (exchangeRequest) => {
|
||||
try {
|
||||
await createExchange(exchangeRequest);
|
||||
setShowAddDialog(false);
|
||||
setSyncStatus({
|
||||
type: 'success',
|
||||
message: `Exchange "${exchangeRequest.code}" created successfully!`,
|
||||
});
|
||||
} catch (error) {
|
||||
setSyncStatus({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Failed to create exchange',
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
|
||||
import { useCallback } from 'react';
|
||||
import { CreateExchangeRequest, AddExchangeDialogProps } from '../types';
|
||||
import { validateExchangeForm } from '../utils/validation';
|
||||
import { useFormValidation } from '../hooks/useFormValidation';
|
||||
|
||||
const initialFormData: CreateExchangeRequest = {
|
||||
code: '',
|
||||
name: '',
|
||||
country: '',
|
||||
currency: '',
|
||||
active: true,
|
||||
};
|
||||
|
||||
export function AddExchangeDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateExchange,
|
||||
}: AddExchangeDialogProps) {
|
||||
const {
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
updateField,
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useFormValidation(initialFormData, validateExchangeForm);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: CreateExchangeRequest) => {
|
||||
await onCreateExchange({
|
||||
...data,
|
||||
code: data.code.toUpperCase(),
|
||||
country: data.country.toUpperCase(),
|
||||
currency: data.currency.toUpperCase(),
|
||||
});
|
||||
},
|
||||
[onCreateExchange]
|
||||
);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit, onClose);
|
||||
},
|
||||
[handleSubmit, onSubmit, onClose]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [reset, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Exchange</DialogTitle>
|
||||
<p className="text-sm text-text-muted">
|
||||
Create a new master exchange with no provider mappings
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
{/* Exchange Code */}
|
||||
<div>
|
||||
<label htmlFor="code" className="block text-sm font-medium text-text-primary mb-1">
|
||||
Exchange Code *
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={e => updateField('code', e.target.value)}
|
||||
placeholder="e.g., NASDAQ, NYSE, TSX"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||
errors.code ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
maxLength={10}
|
||||
required
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-danger mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Exchange Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-primary mb-1">
|
||||
Exchange Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => updateField('name', e.target.value)}
|
||||
placeholder="e.g., NASDAQ Stock Market, New York Stock Exchange"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||
errors.name ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-danger mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-text-primary mb-1">
|
||||
Country Code *
|
||||
</label>
|
||||
<input
|
||||
id="country"
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={e => updateField('country', e.target.value)}
|
||||
placeholder="e.g., US, CA, GB"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||
errors.country ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
maxLength={2}
|
||||
required
|
||||
/>
|
||||
{errors.country && <p className="text-xs text-danger mt-1">{errors.country}</p>}
|
||||
</div>
|
||||
|
||||
{/* Currency */}
|
||||
<div>
|
||||
<label htmlFor="currency" className="block text-sm font-medium text-text-primary mb-1">
|
||||
Currency Code *
|
||||
</label>
|
||||
<input
|
||||
id="currency"
|
||||
type="text"
|
||||
value={formData.currency}
|
||||
onChange={e => updateField('currency', e.target.value)}
|
||||
placeholder="e.g., USD, EUR, CAD"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||
errors.currency ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
{errors.currency && <p className="text-xs text-danger mt-1">{errors.currency}</p>}
|
||||
</div>
|
||||
|
||||
{/* Active Toggle */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={e => updateField('active', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-text-primary">Active exchange</span>
|
||||
</label>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Inactive exchanges won't be used for new symbol mappings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Exchange'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useExchanges } from '../hooks/useExchanges';
|
||||
import { CreateProviderMappingRequest } from '../types';
|
||||
|
||||
interface AddProviderMappingDialogProps {
|
||||
isOpen: boolean;
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
onClose: () => void;
|
||||
onCreateMapping: (request: CreateProviderMappingRequest) => Promise<any>;
|
||||
}
|
||||
|
||||
export function AddProviderMappingDialog({
|
||||
isOpen,
|
||||
exchangeId,
|
||||
exchangeName,
|
||||
onClose,
|
||||
onCreateMapping,
|
||||
}: AddProviderMappingDialogProps) {
|
||||
const { fetchProviders, fetchUnmappedProviderExchanges } = useExchanges();
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState('');
|
||||
const [unmappedExchanges, setUnmappedExchanges] = useState<any[]>([]);
|
||||
const [selectedProviderExchange, setSelectedProviderExchange] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [providersLoading, setProvidersLoading] = useState(false);
|
||||
const [exchangesLoading, setExchangesLoading] = useState(false);
|
||||
|
||||
// Load providers on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadProviders();
|
||||
}
|
||||
}, [isOpen, loadProviders]);
|
||||
|
||||
// Load unmapped exchanges when provider changes
|
||||
useEffect(() => {
|
||||
if (selectedProvider) {
|
||||
loadUnmappedExchanges(selectedProvider);
|
||||
} else {
|
||||
setUnmappedExchanges([]);
|
||||
setSelectedProviderExchange('');
|
||||
}
|
||||
}, [selectedProvider, loadUnmappedExchanges]);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
setProvidersLoading(true);
|
||||
try {
|
||||
const providersData = await fetchProviders();
|
||||
setProviders(providersData);
|
||||
} catch {
|
||||
// Error loading providers - could add toast notification here
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
}, [fetchProviders]);
|
||||
|
||||
const loadUnmappedExchanges = useCallback(
|
||||
async (provider: string) => {
|
||||
setExchangesLoading(true);
|
||||
try {
|
||||
const exchangesData = await fetchUnmappedProviderExchanges(provider);
|
||||
setUnmappedExchanges(exchangesData);
|
||||
} catch {
|
||||
// Error loading unmapped exchanges - could add toast notification here
|
||||
} finally {
|
||||
setExchangesLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchUnmappedProviderExchanges]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedProvider || !selectedProviderExchange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedExchange = unmappedExchanges.find(
|
||||
exchange => exchange.provider_exchange_code === selectedProviderExchange
|
||||
);
|
||||
|
||||
if (!selectedExchange) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request: CreateProviderMappingRequest = {
|
||||
provider: selectedProvider,
|
||||
provider_exchange_code: selectedExchange.provider_exchange_code,
|
||||
provider_exchange_name: selectedExchange.provider_exchange_name,
|
||||
master_exchange_id: exchangeId,
|
||||
country_code: selectedExchange.country_code,
|
||||
currency: selectedExchange.currency,
|
||||
confidence: 1.0,
|
||||
active: false,
|
||||
verified: false,
|
||||
};
|
||||
|
||||
await onCreateMapping(request);
|
||||
} catch {
|
||||
// Error creating provider mapping - could add toast notification here
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[selectedProvider, selectedProviderExchange, unmappedExchanges, exchangeId, onCreateMapping]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedProvider('');
|
||||
setSelectedProviderExchange('');
|
||||
setUnmappedExchanges([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Provider Mapping</DialogTitle>
|
||||
<p className="text-sm text-text-muted">
|
||||
Map a provider exchange to <strong>{exchangeName}</strong>
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Provider Selection */}
|
||||
<div>
|
||||
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="provider"
|
||||
value={selectedProvider}
|
||||
onChange={e => setSelectedProvider(e.target.value)}
|
||||
disabled={providersLoading}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{providersLoading ? 'Loading providers...' : 'Select a provider'}
|
||||
</option>
|
||||
{providers.map(provider => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider Exchange Selection */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="providerExchange"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Provider Exchange
|
||||
</label>
|
||||
<select
|
||||
id="providerExchange"
|
||||
value={selectedProviderExchange}
|
||||
onChange={e => setSelectedProviderExchange(e.target.value)}
|
||||
disabled={!selectedProvider || exchangesLoading}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{!selectedProvider
|
||||
? 'Select a provider first'
|
||||
: exchangesLoading
|
||||
? 'Loading exchanges...'
|
||||
: unmappedExchanges.length === 0
|
||||
? 'No unmapped exchanges available'
|
||||
: 'Select an exchange'}
|
||||
</option>
|
||||
{unmappedExchanges.map(exchange => (
|
||||
<option key={exchange.provider_exchange_code} value={exchange.provider_exchange_code}>
|
||||
{exchange.provider_exchange_code} - {exchange.provider_exchange_name}
|
||||
{exchange.country_code && ` (${exchange.country_code})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedProvider && unmappedExchanges.length === 0 && !exchangesLoading && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
All exchanges for this provider are already mapped.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Exchange Info */}
|
||||
{selectedProviderExchange && (
|
||||
<div className="p-3 bg-surface-secondary rounded-md">
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2">Selected Exchange Info</h4>
|
||||
{(() => {
|
||||
const exchange = unmappedExchanges.find(
|
||||
ex => ex.provider_exchange_code === selectedProviderExchange
|
||||
);
|
||||
if (!exchange) {return null;}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div>
|
||||
<span className="text-text-muted">Code:</span>{' '}
|
||||
<span className="font-mono">{exchange.provider_exchange_code}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Name:</span> {exchange.provider_exchange_name}
|
||||
</div>
|
||||
{exchange.country_code && (
|
||||
<div>
|
||||
<span className="text-text-muted">Country:</span> {exchange.country_code}
|
||||
</div>
|
||||
)}
|
||||
{exchange.currency && (
|
||||
<div>
|
||||
<span className="text-text-muted">Currency:</span> {exchange.currency}
|
||||
</div>
|
||||
)}
|
||||
{exchange.symbol_count && (
|
||||
<div>
|
||||
<span className="text-text-muted">Symbols:</span> {exchange.symbol_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedProvider || !selectedProviderExchange || loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Mapping'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import React, { useState } from 'react';
|
||||
import { AddSourceRequest } from '../types';
|
||||
|
||||
interface AddSourceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddSource: (request: AddSourceRequest) => Promise<void>;
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
}
|
||||
|
||||
export function AddSourceDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddSource,
|
||||
exchangeName,
|
||||
}: AddSourceDialogProps) {
|
||||
const [source, setSource] = useState('');
|
||||
const [sourceCode, setSourceCode] = useState('');
|
||||
const [id, setId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [aliases, setAliases] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!source || !sourceCode || !id || !name || !code) {return;}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onAddSource({
|
||||
source,
|
||||
source_code: sourceCode,
|
||||
mapping: {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
aliases: aliases
|
||||
.split(',')
|
||||
.map(a => a.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setSource('');
|
||||
setSourceCode('');
|
||||
setId('');
|
||||
setName('');
|
||||
setCode('');
|
||||
setAliases('');
|
||||
} catch (_error) {
|
||||
// TODO: Implement proper error handling/toast notification
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error adding source:', _error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-lg bg-background border border-border p-6 text-left align-middle shadow-xl transition-all">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-medium text-text-primary">
|
||||
Add Source to {exchangeName}
|
||||
</Dialog.Title>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Source Provider
|
||||
</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select a source</option>
|
||||
<option value="ib">Interactive Brokers</option>
|
||||
<option value="alpaca">Alpaca</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
<option value="yahoo">Yahoo Finance</option>
|
||||
<option value="alpha_vantage">Alpha Vantage</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Source Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sourceCode}
|
||||
onChange={e => setSourceCode(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., IB, ALP, POLY"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Source ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={e => setId(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., NYSE, NASDAQ"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Source Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., New York Stock Exchange"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Source Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., NYSE"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Aliases (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aliases}
|
||||
onChange={e => setAliases(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., NYSE, New York, Big Board"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-border text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !source || !sourceCode || !id || !name || !code}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Adding...' : 'Add Source'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { TrashIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface DeleteExchangeDialogProps {
|
||||
isOpen: boolean;
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
providerMappingCount: number;
|
||||
onClose: () => void;
|
||||
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function DeleteExchangeDialog({
|
||||
isOpen,
|
||||
exchangeId,
|
||||
exchangeName,
|
||||
providerMappingCount,
|
||||
onClose,
|
||||
onConfirmDelete,
|
||||
}: DeleteExchangeDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await onConfirmDelete(exchangeId);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
} catch {
|
||||
// Error deleting exchange - could add toast notification here
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [exchangeId, onConfirmDelete, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-danger">
|
||||
<ExclamationTriangleIcon className="h-5 w-5" />
|
||||
Delete Exchange
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-danger/10 border border-danger/20 rounded-lg">
|
||||
<p className="text-sm text-text-primary">
|
||||
Are you sure you want to delete <strong>"{exchangeName}"</strong>?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-text-secondary">
|
||||
<p className="font-medium text-text-primary">This action will:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Hide the exchange from all lists</li>
|
||||
{providerMappingCount > 0 && (
|
||||
<li className="text-yellow-400">
|
||||
Delete {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}
|
||||
</li>
|
||||
)}
|
||||
<li>Make provider exchanges available for remapping</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{providerMappingCount > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-400">Warning</p>
|
||||
<p className="text-text-secondary">
|
||||
This exchange has {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}.
|
||||
Deleting will permanently remove these mappings and make the provider exchanges
|
||||
available for mapping to other exchanges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface-secondary p-3 rounded-md">
|
||||
<p className="text-xs text-text-muted">
|
||||
<strong>Note:</strong> This action cannot be undone. The exchange will be hidden
|
||||
but can be restored by directly updating the database if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className="bg-danger hover:bg-danger/90 text-white"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 mr-1" />
|
||||
{loading ? 'Deleting...' : 'Delete Exchange'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
import { DataTable } from '@/components/ui';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useExchanges } from '../hooks/useExchanges';
|
||||
import { Exchange, EditingCell, AddProviderMappingDialogState, DeleteDialogState } from '../types';
|
||||
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
||||
import { DeleteExchangeDialog } from './DeleteExchangeDialog';
|
||||
import { sortProviderMappings, getProviderMappingColor, formatProviderMapping, formatDate } from '../utils/formatters';
|
||||
|
||||
export function ExchangesTable() {
|
||||
const {
|
||||
exchanges,
|
||||
loading,
|
||||
error,
|
||||
updateExchange,
|
||||
updateProviderMapping,
|
||||
createProviderMapping,
|
||||
refetch
|
||||
} = useExchanges();
|
||||
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [addProviderDialog, setAddProviderDialog] = useState<AddProviderMappingDialogState | null>(null);
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
|
||||
const handleCellEdit = useCallback(
|
||||
async (id: string, field: string, value: string) => {
|
||||
if (field === 'name' || field === 'code') {
|
||||
await updateExchange(id, { [field]: value });
|
||||
}
|
||||
setEditingCell(null);
|
||||
setEditValue('');
|
||||
},
|
||||
[updateExchange]
|
||||
);
|
||||
|
||||
const handleToggleActive = useCallback(
|
||||
async (id: string, currentStatus: boolean) => {
|
||||
await updateExchange(id, { active: !currentStatus });
|
||||
},
|
||||
[updateExchange]
|
||||
);
|
||||
|
||||
const handleAddProviderMapping = useCallback(async (exchangeId: string, exchangeName: string) => {
|
||||
setAddProviderDialog({ exchangeId, exchangeName });
|
||||
}, []);
|
||||
|
||||
const handleDeleteExchange = useCallback((exchangeId: string, exchangeName: string, providerMappingCount: number) => {
|
||||
setDeleteDialog({ exchangeId, exchangeName, providerMappingCount });
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async (exchangeId: string) => {
|
||||
const success = await updateExchange(exchangeId, { visible: false });
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
return success;
|
||||
}, [updateExchange, refetch]);
|
||||
|
||||
const handleToggleProviderMapping = useCallback(
|
||||
async (mappingId: string, currentStatus: boolean) => {
|
||||
const success = await updateProviderMapping(mappingId, { active: !currentStatus });
|
||||
if (success) {
|
||||
// Refresh the main table data to get updated counts and mappings
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[updateProviderMapping, refetch]
|
||||
);
|
||||
|
||||
const handleRowExpand = useCallback(
|
||||
async (_row: any) => {
|
||||
// Row expansion is now handled automatically by TanStack Table
|
||||
// No need to fetch data since all mappings are already loaded
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'expander',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
return row.getCanExpand() ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
row.getToggleExpandedHandler()();
|
||||
handleRowExpand(row);
|
||||
}}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors w-6 h-6 flex items-center justify-center"
|
||||
>
|
||||
{row.getIsExpanded() ? '▼' : '▶'}
|
||||
</button>
|
||||
) : null;
|
||||
},
|
||||
size: 40,
|
||||
enableResizing: false,
|
||||
disableTooltip: true,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
header: 'Code',
|
||||
accessorKey: 'code',
|
||||
size: 120,
|
||||
cell: ({ getValue, row, cell: _cell }) => {
|
||||
const isEditing =
|
||||
editingCell?.id === row.original.id && editingCell?.field === 'code';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={() => handleCellEdit(row.original.id, 'code', editValue)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCellEdit(row.original.id, 'code', editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingCell(null);
|
||||
setEditValue('');
|
||||
}
|
||||
}}
|
||||
className="w-full bg-surface border border-border rounded px-2 py-1 text-sm font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-surface-secondary rounded px-2 py-1 transition-colors text-sm font-mono font-medium truncate overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setEditingCell({ id: row.original.id, field: 'code' });
|
||||
setEditValue(getValue() as string);
|
||||
}}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
size: 250,
|
||||
cell: ({ getValue, row, cell: _cell }) => {
|
||||
const isEditing =
|
||||
editingCell?.id === row.original.id && editingCell?.field === 'name';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={() => handleCellEdit(row.original.id, 'name', editValue)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCellEdit(row.original.id, 'name', editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingCell(null);
|
||||
setEditValue('');
|
||||
}
|
||||
}}
|
||||
className="w-full bg-surface border border-border rounded px-2 py-1 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-surface-secondary rounded px-2 py-1 transition-colors text-sm truncate overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setEditingCell({ id: row.original.id, field: 'name' });
|
||||
setEditValue(getValue() as string);
|
||||
}}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
header: 'Country',
|
||||
accessorKey: 'country',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-text-secondary text-sm block truncate">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currency',
|
||||
header: 'Currency',
|
||||
accessorKey: 'currency',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono text-text-secondary text-sm block truncate">
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
accessorKey: 'active',
|
||||
size: 80,
|
||||
disableTooltip: true,
|
||||
cell: ({ getValue, row }) => {
|
||||
const isActive = getValue() as boolean;
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={() => handleToggleActive(row.original.id, isActive)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-surface-secondary peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider_mappings',
|
||||
header: 'Provider Mappings',
|
||||
accessorKey: 'provider_mapping_count',
|
||||
size: 180,
|
||||
disableTooltip: true,
|
||||
cell: ({ getValue, row }) => {
|
||||
const totalMappings = parseInt(getValue() as string) || 0;
|
||||
const activeMappings = parseInt(row.original.active_mapping_count) || 0;
|
||||
const _verifiedMappings = parseInt(row.original.verified_mapping_count) || 0;
|
||||
|
||||
// Get provider mappings directly from the exchange data
|
||||
const mappings = row.original.provider_mappings || [];
|
||||
const sortedMappings = sortProviderMappings(mappings);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">
|
||||
<span className="text-text-primary font-medium">{totalMappings}</span>
|
||||
<span className="text-text-muted"> total</span>
|
||||
{activeMappings > 0 && (
|
||||
<span className="ml-2 text-green-400 text-xs">
|
||||
{activeMappings} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{mappings.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
{sortedMappings.slice(0, 3).map((mapping, index) => (
|
||||
<span key={index} className={getProviderMappingColor(mapping)}>
|
||||
{formatProviderMapping(mapping)}
|
||||
</span>
|
||||
))}
|
||||
{sortedMappings.length > 3 && (
|
||||
<span className="text-text-muted">+{sortedMappings.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-muted">No mappings</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: 160,
|
||||
disableTooltip: true,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleAddProviderMapping(row.original.id, row.original.name)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors whitespace-nowrap"
|
||||
title="Add Provider Mapping"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteExchange(
|
||||
row.original.id,
|
||||
row.original.name,
|
||||
parseInt(row.original.provider_mapping_count) || 0
|
||||
)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-danger/20 text-danger rounded text-xs hover:bg-danger/30 transition-colors whitespace-nowrap"
|
||||
title="Delete Exchange"
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
accessorKey: 'updated_at',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatDate(getValue() as string)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
editingCell,
|
||||
editValue,
|
||||
handleCellEdit,
|
||||
handleToggleActive,
|
||||
handleAddProviderMapping,
|
||||
handleDeleteExchange,
|
||||
handleConfirmDelete,
|
||||
handleRowExpand,
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger/10 border border-danger/20 rounded-lg p-4">
|
||||
<h3 className="text-danger font-medium mb-2">Error Loading Exchanges</h3>
|
||||
<p className="text-text-secondary text-sm">{error}</p>
|
||||
<p className="text-text-muted text-xs mt-2">
|
||||
Make sure the web-api service is running on localhost:4000
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSubComponent = ({ row }: { row: any }) => {
|
||||
const exchange = row.original as Exchange;
|
||||
const mappings = exchange.provider_mappings || [];
|
||||
|
||||
if (mappings.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-text-muted">
|
||||
<div className="text-sm">No provider mappings found for this exchange.</div>
|
||||
<button
|
||||
onClick={() => handleAddProviderMapping(exchange.id, exchange.name)}
|
||||
className="mt-2 text-primary-400 hover:text-primary-300 text-sm underline"
|
||||
>
|
||||
Add the first provider mapping
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-surface-secondary/50">
|
||||
<h4 className="text-sm font-medium text-text-primary mb-3">Provider Mappings</h4>
|
||||
<div className="space-y-2">
|
||||
{mappings.map((mapping) => (
|
||||
<div key={mapping.id} className="flex items-center justify-between p-3 bg-surface rounded border border-border">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs bg-primary-500/20 text-primary-400 px-2 py-1 rounded">
|
||||
{mapping.provider.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{mapping.provider_exchange_code}
|
||||
</span>
|
||||
<span className="text-text-secondary">
|
||||
{mapping.provider_exchange_name}
|
||||
</span>
|
||||
{mapping.country_code && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{mapping.country_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
|
||||
<span>Confidence: {mapping.confidence}</span>
|
||||
<span>Created: {formatDate(mapping.created_at)}</span>
|
||||
{mapping.auto_mapped && <span className="text-yellow-400">Auto-mapped</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{mapping.verified && (
|
||||
<span className="text-blue-400" title="Verified">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mapping.active}
|
||||
onChange={() => handleToggleProviderMapping(mapping.id, mapping.active)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-6 h-3 bg-surface-secondary peer-focus:outline-none peer-focus:ring-1 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[1px] after:left-[1px] after:bg-white after:rounded-full after:h-2.5 after:w-2.5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={exchanges || []}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={renderSubComponent}
|
||||
/>
|
||||
|
||||
{addProviderDialog && (
|
||||
<AddProviderMappingDialog
|
||||
isOpen={true}
|
||||
exchangeId={addProviderDialog.exchangeId}
|
||||
exchangeName={addProviderDialog.exchangeName}
|
||||
onClose={() => setAddProviderDialog(null)}
|
||||
onCreateMapping={async (mappingRequest) => {
|
||||
const result = await createProviderMapping(mappingRequest);
|
||||
if (result) {
|
||||
setAddProviderDialog(null);
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteDialog && (
|
||||
<DeleteExchangeDialog
|
||||
isOpen={true}
|
||||
exchangeId={deleteDialog.exchangeId}
|
||||
exchangeName={deleteDialog.exchangeName}
|
||||
providerMappingCount={deleteDialog.providerMappingCount}
|
||||
onClose={() => setDeleteDialog(null)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { AddSourceDialog } from './AddSourceDialog';
|
||||
export { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
||||
export { AddExchangeDialog } from './AddExchangeDialog';
|
||||
export { DeleteExchangeDialog } from './DeleteExchangeDialog';
|
||||
export { ExchangesTable } from './ExchangesTable';
|
||||
1
apps/stock/web-app/src/features/exchanges/hooks/index.ts
Normal file
1
apps/stock/web-app/src/features/exchanges/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useExchanges } from './useExchanges';
|
||||
167
apps/stock/web-app/src/features/exchanges/hooks/useExchanges.ts
Normal file
167
apps/stock/web-app/src/features/exchanges/hooks/useExchanges.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { exchangeApi } from '../services/exchangeApi';
|
||||
import {
|
||||
CreateExchangeRequest,
|
||||
CreateProviderMappingRequest,
|
||||
Exchange,
|
||||
ExchangeDetails,
|
||||
ExchangeStats,
|
||||
ProviderExchange,
|
||||
ProviderMapping,
|
||||
UpdateExchangeRequest,
|
||||
UpdateProviderMappingRequest,
|
||||
} from '../types';
|
||||
|
||||
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 data = await exchangeApi.getExchanges();
|
||||
setExchanges(data);
|
||||
} catch (err) {
|
||||
// Error fetching exchanges - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
|
||||
setExchanges([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateExchange = useCallback(
|
||||
async (id: string, updates: UpdateExchangeRequest): Promise<boolean> => {
|
||||
try {
|
||||
await exchangeApi.updateExchange(id, updates);
|
||||
await fetchExchanges(); // Refresh the list
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Error updating exchange - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to update exchange');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchExchanges]
|
||||
);
|
||||
|
||||
const createExchange = useCallback(
|
||||
async (request: CreateExchangeRequest): Promise<Exchange> => {
|
||||
try {
|
||||
const exchange = await exchangeApi.createExchange(request);
|
||||
await fetchExchanges(); // Refresh the list
|
||||
return exchange;
|
||||
} catch (err) {
|
||||
// Error creating exchange - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to create exchange');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[fetchExchanges]
|
||||
);
|
||||
|
||||
const fetchExchangeDetails = useCallback(async (id: string): Promise<ExchangeDetails | null> => {
|
||||
try {
|
||||
return await exchangeApi.getExchangeById(id);
|
||||
} catch (err) {
|
||||
// Error fetching exchange details - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchange details');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async (): Promise<ExchangeStats | null> => {
|
||||
try {
|
||||
return await exchangeApi.getExchangeStats();
|
||||
} catch (err) {
|
||||
// Error fetching stats - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchProviderMappings = useCallback(
|
||||
async (provider?: string): Promise<ProviderMapping[]> => {
|
||||
try {
|
||||
return await exchangeApi.getProviderMappings(provider);
|
||||
} catch (err) {
|
||||
// Error fetching provider mappings - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch provider mappings');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateProviderMapping = useCallback(
|
||||
async (id: string, updates: UpdateProviderMappingRequest): Promise<boolean> => {
|
||||
try {
|
||||
await exchangeApi.updateProviderMapping(id, updates);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Error updating provider mapping - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to update provider mapping');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createProviderMapping = useCallback(
|
||||
async (request: CreateProviderMappingRequest): Promise<ProviderMapping | null> => {
|
||||
try {
|
||||
return await exchangeApi.createProviderMapping(request);
|
||||
} catch (err) {
|
||||
// Error creating provider mapping - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to create provider mapping');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchProviders = useCallback(async (): Promise<string[]> => {
|
||||
try {
|
||||
return await exchangeApi.getProviders();
|
||||
} catch (err) {
|
||||
// Error fetching providers - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUnmappedProviderExchanges = useCallback(
|
||||
async (provider: string): Promise<ProviderExchange[]> => {
|
||||
try {
|
||||
return await exchangeApi.getUnmappedProviderExchanges(provider);
|
||||
} catch (err) {
|
||||
// Error fetching unmapped exchanges - error state will show in UI
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch unmapped exchanges');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExchanges();
|
||||
}, [fetchExchanges]);
|
||||
|
||||
return {
|
||||
exchanges,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchExchanges,
|
||||
updateExchange,
|
||||
createExchange,
|
||||
fetchExchangeDetails,
|
||||
fetchStats,
|
||||
fetchProviderMappings,
|
||||
updateProviderMapping,
|
||||
createProviderMapping,
|
||||
fetchProviders,
|
||||
fetchUnmappedProviderExchanges,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { FormErrors } from '../types';
|
||||
|
||||
export function useFormValidation<T>(initialData: T, validateFn: (data: T) => FormErrors) {
|
||||
const [formData, setFormData] = useState<T>(initialData);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const updateField = useCallback(
|
||||
(field: keyof T, value: T[keyof T]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field as string]) {
|
||||
setErrors(prev => ({ ...prev, [field as string]: '' }));
|
||||
}
|
||||
},
|
||||
[errors]
|
||||
);
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const newErrors = validateFn(formData);
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [formData, validateFn]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFormData(initialData);
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
}, [initialData]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
onSubmit: (data: T) => Promise<void>,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void
|
||||
) => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
reset();
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[formData, validate, reset]
|
||||
);
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
updateField,
|
||||
validate,
|
||||
reset,
|
||||
handleSubmit,
|
||||
setIsSubmitting,
|
||||
};
|
||||
}
|
||||
4
apps/stock/web-app/src/features/exchanges/index.ts
Normal file
4
apps/stock/web-app/src/features/exchanges/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './components';
|
||||
export { ExchangesPage } from './ExchangesPage';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import {
|
||||
ApiResponse,
|
||||
CreateExchangeRequest,
|
||||
CreateProviderMappingRequest,
|
||||
Exchange,
|
||||
ExchangeDetails,
|
||||
ExchangeStats,
|
||||
ProviderExchange,
|
||||
ProviderMapping,
|
||||
UpdateExchangeRequest,
|
||||
UpdateProviderMappingRequest,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
|
||||
|
||||
class ExchangeApiService {
|
||||
private async request<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'API request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Exchanges
|
||||
async getExchanges(): Promise<Exchange[]> {
|
||||
const response = await this.request<Exchange[]>('/exchanges');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
async getExchangeById(id: string): Promise<ExchangeDetails | null> {
|
||||
const response = await this.request<ExchangeDetails>(`/exchanges/${id}`);
|
||||
return response.data || null;
|
||||
}
|
||||
|
||||
async createExchange(data: CreateExchangeRequest): Promise<Exchange> {
|
||||
const response = await this.request<Exchange>('/exchanges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error('No exchange data returned');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateExchange(id: string, data: UpdateExchangeRequest): Promise<Exchange> {
|
||||
const response = await this.request<Exchange>(`/exchanges/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error('No exchange data returned');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Provider Mappings
|
||||
async getProviderMappings(provider?: string): Promise<ProviderMapping[]> {
|
||||
const endpoint = provider
|
||||
? `/exchanges/provider-mappings/${provider}`
|
||||
: '/exchanges/provider-mappings/all';
|
||||
|
||||
const response = await this.request<ProviderMapping[]>(endpoint);
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
async createProviderMapping(data: CreateProviderMappingRequest): Promise<ProviderMapping> {
|
||||
const response = await this.request<ProviderMapping>('/exchanges/provider-mappings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error('No provider mapping data returned');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProviderMapping(
|
||||
id: string,
|
||||
data: UpdateProviderMappingRequest
|
||||
): Promise<ProviderMapping> {
|
||||
const response = await this.request<ProviderMapping>(`/exchanges/provider-mappings/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error('No provider mapping data returned');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Providers and Utilities
|
||||
async getProviders(): Promise<string[]> {
|
||||
const response = await this.request<string[]>('/exchanges/providers/list');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
async getUnmappedProviderExchanges(provider: string): Promise<ProviderExchange[]> {
|
||||
const response = await this.request<ProviderExchange[]>(
|
||||
`/exchanges/provider-exchanges/unmapped/${provider}`
|
||||
);
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
async getExchangeStats(): Promise<ExchangeStats> {
|
||||
const response = await this.request<ExchangeStats>('/exchanges/stats/summary');
|
||||
if (!response.data) {
|
||||
throw new Error('No exchange stats data returned');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const exchangeApi = new ExchangeApiService();
|
||||
69
apps/stock/web-app/src/features/exchanges/types/api.types.ts
Normal file
69
apps/stock/web-app/src/features/exchanges/types/api.types.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// API Response types
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// Base entity types
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProviderMapping extends BaseEntity {
|
||||
provider: string;
|
||||
provider_exchange_code: string;
|
||||
provider_exchange_name: string;
|
||||
master_exchange_id: string;
|
||||
country_code: string | null;
|
||||
currency: string | null;
|
||||
confidence: number;
|
||||
active: boolean;
|
||||
verified: boolean;
|
||||
auto_mapped: boolean;
|
||||
master_exchange_code?: string;
|
||||
master_exchange_name?: string;
|
||||
master_exchange_active?: boolean;
|
||||
}
|
||||
|
||||
export interface Exchange extends BaseEntity {
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
active: boolean;
|
||||
visible: boolean;
|
||||
provider_mapping_count: string;
|
||||
active_mapping_count: string;
|
||||
verified_mapping_count: string;
|
||||
providers: string | null;
|
||||
provider_mappings: ProviderMapping[];
|
||||
}
|
||||
|
||||
export interface ExchangeDetails {
|
||||
exchange: Exchange;
|
||||
provider_mappings: ProviderMapping[];
|
||||
}
|
||||
|
||||
export interface ProviderExchange {
|
||||
provider_exchange_code: string;
|
||||
provider_exchange_name: string;
|
||||
country_code: string | null;
|
||||
currency: string | null;
|
||||
symbol_count: number | null;
|
||||
}
|
||||
|
||||
export interface ExchangeStats {
|
||||
total_exchanges: string;
|
||||
active_exchanges: string;
|
||||
countries: string;
|
||||
currencies: string;
|
||||
total_provider_mappings: string;
|
||||
active_provider_mappings: string;
|
||||
verified_provider_mappings: string;
|
||||
providers: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Component-specific types
|
||||
export interface EditingCell {
|
||||
id: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface AddProviderMappingDialogState {
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
}
|
||||
|
||||
export interface DeleteDialogState {
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
providerMappingCount: number;
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// Dialog props interfaces
|
||||
export interface BaseDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AddExchangeDialogProps extends BaseDialogProps {
|
||||
onCreateExchange: (request: import('./request.types').CreateExchangeRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AddProviderMappingDialogProps extends BaseDialogProps {
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
onCreateMapping: (
|
||||
request: import('./request.types').CreateProviderMappingRequest
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface DeleteExchangeDialogProps extends BaseDialogProps {
|
||||
exchangeId: string;
|
||||
exchangeName: string;
|
||||
providerMappingCount: number;
|
||||
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
|
||||
}
|
||||
7
apps/stock/web-app/src/features/exchanges/types/index.ts
Normal file
7
apps/stock/web-app/src/features/exchanges/types/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Re-export all types from organized files
|
||||
export * from './api.types';
|
||||
export * from './request.types';
|
||||
export * from './component.types';
|
||||
|
||||
// Legacy compatibility - can be removed later
|
||||
export type ExchangesApiResponse<T = unknown> = import('./api.types').ApiResponse<T>;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// Request types for API calls
|
||||
export interface CreateExchangeRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateExchangeRequest {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
visible?: boolean;
|
||||
country?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface CreateProviderMappingRequest {
|
||||
provider: string;
|
||||
provider_exchange_code: string;
|
||||
provider_exchange_name?: string;
|
||||
master_exchange_id: string;
|
||||
country_code?: string;
|
||||
currency?: string;
|
||||
confidence?: number;
|
||||
active?: boolean;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProviderMappingRequest {
|
||||
active?: boolean;
|
||||
verified?: boolean;
|
||||
confidence?: number;
|
||||
master_exchange_id?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { ProviderMapping } from '../types';
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function formatProviderMapping(mapping: ProviderMapping): string {
|
||||
return `${mapping.provider.toLowerCase()}(${mapping.provider_exchange_code})`;
|
||||
}
|
||||
|
||||
export function getProviderMappingColor(mapping: ProviderMapping): string {
|
||||
return mapping.active ? 'text-green-500' : 'text-text-muted';
|
||||
}
|
||||
|
||||
export function sortProviderMappings(mappings: ProviderMapping[]): ProviderMapping[] {
|
||||
return [...mappings].sort((a, b) => {
|
||||
// Active mappings first
|
||||
if (a.active && !b.active) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.active && b.active) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then by provider name
|
||||
return a.provider.localeCompare(b.provider);
|
||||
});
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { FormErrors } from '../types';
|
||||
|
||||
export function validateExchangeForm(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
}): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!data.code.trim()) {
|
||||
errors.code = 'Exchange code is required';
|
||||
} else if (data.code.length > 10) {
|
||||
errors.code = 'Exchange code must be 10 characters or less';
|
||||
}
|
||||
|
||||
if (!data.name.trim()) {
|
||||
errors.name = 'Exchange name is required';
|
||||
}
|
||||
|
||||
if (!data.country.trim()) {
|
||||
errors.country = 'Country is required';
|
||||
} else if (data.country.length !== 2) {
|
||||
errors.country = 'Country must be exactly 2 characters (e.g., US, CA, GB)';
|
||||
}
|
||||
|
||||
if (!data.currency.trim()) {
|
||||
errors.currency = 'Currency is required';
|
||||
} else if (data.currency.length !== 3) {
|
||||
errors.currency = 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)';
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function hasValidationErrors(errors: FormErrors): boolean {
|
||||
return Object.keys(errors).length > 0;
|
||||
}
|
||||
132
apps/stock/web-app/src/index.css
Normal file
132
apps/stock/web-app/src/index.css
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0 0;
|
||||
--background-secondary: 0 0 0;
|
||||
--background-tertiary: 0 0 0;
|
||||
--surface: 0 0 0;
|
||||
--surface-secondary: 10 10 10;
|
||||
--surface-tertiary: 17 17 17;
|
||||
--border: 26 26 26;
|
||||
--border-secondary: 42 42 42;
|
||||
--text-primary: 255 255 255;
|
||||
--text-secondary: 161 161 170;
|
||||
--text-muted: 113 113 122;
|
||||
--primary: 99 102 241;
|
||||
--primary-hover: 79 70 229;
|
||||
--secondary: 16 185 129;
|
||||
--accent: 245 158 11;
|
||||
--destructive: 239 68 68;
|
||||
--success: 34 197 94;
|
||||
--warning: 251 191 36;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply h-full bg-black text-white font-sans antialiased text-sm leading-snug;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Global dark-themed scrollbars with better contrast */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(115 115 115) rgb(40 40 40);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgb(40 40 40);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(115 115 115);
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgb(40 40 40);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(140 140 140);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background-color: rgb(160 160 160);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: rgb(40 40 40);
|
||||
}
|
||||
|
||||
/* Trading specific styles */
|
||||
.profit {
|
||||
@apply text-success;
|
||||
}
|
||||
|
||||
.loss {
|
||||
@apply text-danger;
|
||||
}
|
||||
|
||||
.neutral {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
/* Compact component utilities */
|
||||
.compact-card {
|
||||
@apply bg-surface-secondary p-3 rounded border border-border text-sm;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
@apply px-2 py-1 text-sm rounded border border-border bg-surface hover:bg-surface-secondary transition-colors;
|
||||
}
|
||||
|
||||
.compact-input {
|
||||
@apply px-2 py-1 text-sm rounded border border-border bg-surface focus:ring-1 focus:ring-primary-500 focus:border-primary-500;
|
||||
}
|
||||
|
||||
.compact-nav-item {
|
||||
@apply group flex gap-x-2 rounded-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky !important;
|
||||
position: -webkit-sticky !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.row,
|
||||
.sticky {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #eee;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
17
apps/stock/web-app/src/lib/constants.ts
Normal file
17
apps/stock/web-app/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
apps/stock/web-app/src/lib/constants/index.ts
Normal file
1
apps/stock/web-app/src/lib/constants/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './navigation';
|
||||
47
apps/stock/web-app/src/lib/constants/navigation.ts
Normal file
47
apps/stock/web-app/src/lib/constants/navigation.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
BuildingOfficeIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon,
|
||||
CurrencyDollarIcon,
|
||||
DocumentTextIcon,
|
||||
HomeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/',
|
||||
icon: HomeIcon,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
name: 'Portfolio',
|
||||
href: '/portfolio',
|
||||
icon: CurrencyDollarIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
href: '/analytics',
|
||||
icon: ChartBarIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Exchanges',
|
||||
href: '/exchanges',
|
||||
icon: BuildingOfficeIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/reports',
|
||||
icon: DocumentTextIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: CogIcon,
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
29
apps/stock/web-app/src/lib/utils.ts
Normal file
29
apps/stock/web-app/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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';
|
||||
}
|
||||
6
apps/stock/web-app/src/lib/utils/cn.ts
Normal file
6
apps/stock/web-app/src/lib/utils/cn.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
59
apps/stock/web-app/src/lib/utils/index.ts
Normal file
59
apps/stock/web-app/src/lib/utils/index.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export * from './cn';
|
||||
|
||||
/**
|
||||
* Format currency values
|
||||
*/
|
||||
export function formatCurrency(value: number, currency = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage values
|
||||
*/
|
||||
export function formatPercentage(value: number, decimals = 2): string {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with K, M, B suffixes
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1e9) {
|
||||
return (num / 1e9).toFixed(1) + 'B';
|
||||
}
|
||||
if (num >= 1e6) {
|
||||
return (num / 1e6).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1e3) {
|
||||
return (num / 1e3).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class based on numeric value (profit/loss)
|
||||
*/
|
||||
export function getValueColor(value: number): string {
|
||||
if (value > 0) {
|
||||
return 'text-success';
|
||||
}
|
||||
if (value < 0) {
|
||||
return 'text-danger';
|
||||
}
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to specified length
|
||||
*/
|
||||
export function truncateText(text: string, length: number): string {
|
||||
if (text.length <= length) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, length) + '...';
|
||||
}
|
||||
15
apps/stock/web-app/src/main.tsx
Normal file
15
apps/stock/web-app/src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import './index.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
93
apps/stock/web-app/tailwind.config.js
Normal file
93
apps/stock/web-app/tailwind.config.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Dark theme color palette for trading/finance - All black backgrounds
|
||||
background: {
|
||||
DEFAULT: '#000000',
|
||||
secondary: '#000000',
|
||||
tertiary: '#000000',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: '#000000',
|
||||
secondary: '#0a0a0a',
|
||||
tertiary: '#111111',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#1a1a1a',
|
||||
secondary: '#2a2a2a',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#a1a1aa',
|
||||
muted: '#71717a',
|
||||
},
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#10b981',
|
||||
50: '#ecfdf5',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#ef4444',
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#f59e0b',
|
||||
50: '#fffbeb',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
spacing: {
|
||||
'0.5': '0.125rem', // 2px
|
||||
'1.5': '0.375rem', // 6px
|
||||
'2.5': '0.625rem', // 10px
|
||||
'3.5': '0.875rem', // 14px
|
||||
'4.5': '1.125rem', // 18px
|
||||
'5.5': '1.375rem', // 22px
|
||||
},
|
||||
fontSize: {
|
||||
'xs': ['0.6875rem', { lineHeight: '0.875rem' }], // 11px
|
||||
'sm': ['0.75rem', { lineHeight: '1rem' }], // 12px
|
||||
'base': ['0.8125rem', { lineHeight: '1.125rem' }], // 13px
|
||||
'lg': ['0.875rem', { lineHeight: '1.25rem' }], // 14px
|
||||
'xl': ['1rem', { lineHeight: '1.375rem' }], // 16px
|
||||
'2xl': ['1.125rem', { lineHeight: '1.5rem' }], // 18px
|
||||
'3xl': ['1.25rem', { lineHeight: '1.625rem' }], // 20px
|
||||
},
|
||||
lineHeight: {
|
||||
'tight': '1.1',
|
||||
'snug': '1.2',
|
||||
'normal': '1.3',
|
||||
'relaxed': '1.4',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
26
apps/stock/web-app/tsconfig.json
Normal file
26
apps/stock/web-app/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
apps/stock/web-app/tsconfig.node.json
Normal file
10
apps/stock/web-app/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
apps/stock/web-app/vite.config.ts
Normal file
17
apps/stock/web-app/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { fileURLToPath, URL } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue