removed angular app and switched to react
This commit is contained in:
parent
56e3938561
commit
3a9d45c543
101 changed files with 2697 additions and 4075 deletions
|
|
@ -1,17 +0,0 @@
|
|||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
apps/dashboard/.gitignore
vendored
42
apps/dashboard/.gitignore
vendored
|
|
@ -1,42 +0,0 @@
|
|||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
4
apps/dashboard/.vscode/extensions.json
vendored
4
apps/dashboard/.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
apps/dashboard/.vscode/launch.json
vendored
20
apps/dashboard/.vscode/launch.json
vendored
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
apps/dashboard/.vscode/tasks.json
vendored
42
apps/dashboard/.vscode/tasks.json
vendored
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# TradingDashboard
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.0.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"trading-dashboard": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "css"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "css",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "trading-dashboard:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "trading-dashboard:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "css",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"name": "trading-dashboard",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"devvvv": "ng serve --port 5173 --host 0.0.0.0",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/cdk": "^20.0.1",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/material": "^20.0.1",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.0.0",
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/jasmine": "~5.1.8",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jasmine-core": "~5.7.1",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1,19 +0,0 @@
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/* Custom Angular Material integration styles */
|
||||
|
||||
/* Sidenav styles */
|
||||
.mat-sidenav-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mat-sidenav {
|
||||
border-radius: 0;
|
||||
width: 16rem;
|
||||
background-color: white !important;
|
||||
border-right: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
/* Toolbar styles */
|
||||
.mat-toolbar {
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.mat-mdc-button.nav-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.mat-mdc-button.nav-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.mat-mdc-button.nav-button.bg-blue-50 {
|
||||
background-color: #eff6ff !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.mat-mdc-card {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Tab styles */
|
||||
.mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label-active {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Chip styles for status indicators */
|
||||
.mat-mdc-chip-set .mat-mdc-chip {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chip-green {
|
||||
background-color: #dcfce7 !important;
|
||||
color: #166534 !important;
|
||||
border: 1px solid #bbf7d0 !important;
|
||||
}
|
||||
|
||||
.chip-blue {
|
||||
background-color: #dbeafe !important;
|
||||
color: #1e40af !important;
|
||||
border: 1px solid #bfdbfe !important;
|
||||
}
|
||||
|
||||
.status-chip-active {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-chip-medium {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.mat-mdc-table {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f3f4f6;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.mat-mdc-header-row {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.mat-mdc-header-cell {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Custom utility classes for the dashboard */
|
||||
.portfolio-card {
|
||||
background-color: white !important;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-change-positive {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-change-negative {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.mat-sidenav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<!-- Trading Dashboard App -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<app-sidebar [opened]="sidenavOpened()" (navigationItemClick)="onNavigationClick($event)"></app-sidebar>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content" [class.main-content-closed]="!sidenavOpened()">
|
||||
<!-- Top Navigation Bar -->
|
||||
<mat-toolbar class="top-toolbar">
|
||||
<button mat-icon-button (click)="toggleSidenav()" class="mr-2">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button> <span class="text-lg font-semibold text-gray-800">{{ title }}</span>
|
||||
<span class="spacer"></span>
|
||||
<app-notifications></app-notifications>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="page-content">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 256px; /* Width of sidebar */
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
.main-content-closed {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.top-toolbar {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { DashboardComponent } from './pages/dashboard/dashboard.component';
|
||||
import { ExchangesComponent } from './pages/exchanges/exchanges.component';
|
||||
import { MarketDataComponent } from './pages/market-data/market-data.component';
|
||||
import { SettingsComponent } from './pages/settings/settings.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: 'market-data', component: MarketDataComponent },
|
||||
{ path: 'exchanges', component: ExchangesComponent },
|
||||
|
||||
{ path: 'settings', component: SettingsComponent },
|
||||
{ path: '**', redirectTo: '/dashboard' },
|
||||
];
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, trading-dashboard');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
|
||||
import { NotificationsComponent } from './components/notifications/notifications';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
import { BreadcrumbService } from './services/breadcrumb.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
CommonModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
SidebarComponent,
|
||||
NotificationsComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected title = 'Trading Dashboard';
|
||||
protected sidenavOpened = signal(true);
|
||||
|
||||
toggleSidenav() {
|
||||
this.sidenavOpened.set(!this.sidenavOpened());
|
||||
}
|
||||
|
||||
onNavigationClick(route: string) {
|
||||
// Handle navigation if needed
|
||||
console.log('Navigating to:', route);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BreadcrumbItem, BreadcrumbService } from '../../services/breadcrumb.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule],
|
||||
template: `
|
||||
<nav class="breadcrumb-nav" *ngIf="breadcrumbs$ | async as breadcrumbs">
|
||||
<ol class="breadcrumb-list">
|
||||
<li
|
||||
*ngFor="let item of breadcrumbs; let last = last"
|
||||
class="breadcrumb-item"
|
||||
[class.active]="item.isActive"
|
||||
>
|
||||
<a
|
||||
*ngIf="!item.isActive; else activeItem"
|
||||
[routerLink]="item.url"
|
||||
class="breadcrumb-link"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<ng-template #activeItem>
|
||||
<span class="breadcrumb-active">{{ item.label }}</span>
|
||||
</ng-template>
|
||||
<mat-icon *ngIf="!last" class="breadcrumb-separator">chevron_right</mat-icon>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
`,
|
||||
styles: [`
|
||||
.breadcrumb-nav {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.breadcrumb-active {
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #9ca3af;
|
||||
font-size: 1rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BreadcrumbComponent implements OnInit {
|
||||
breadcrumbs$: Observable<BreadcrumbItem[]>;
|
||||
|
||||
constructor(
|
||||
private breadcrumbService: BreadcrumbService,
|
||||
private router: Router
|
||||
) {
|
||||
this.breadcrumbs$ = this.breadcrumbService.breadcrumbs$;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize breadcrumbs for current route
|
||||
this.breadcrumbService.breadcrumbs$.subscribe();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
::ng-deep .notification-menu {
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
white-space: normal !important;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background-color: #f0f9ff;
|
||||
border-left-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.notification-item.unread .font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
[matBadge]="unreadCount"
|
||||
[matBadgeHidden]="unreadCount === 0"
|
||||
matBadgeColor="warn"
|
||||
matBadgeSize="small">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #notificationMenu="matMenu" class="notification-menu">
|
||||
<div mat-menu-item disabled class="notification-header">
|
||||
<div class="flex items-center justify-between w-full px-2">
|
||||
<span class="font-semibold">Notifications</span>
|
||||
@if (notifications.length > 0) {
|
||||
<div class="flex gap-2">
|
||||
<button mat-button (click)="markAllAsRead()" class="text-xs">
|
||||
Mark all read
|
||||
</button>
|
||||
<button mat-button (click)="clearAll()" class="text-xs">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
@if (notifications.length === 0) {
|
||||
<div mat-menu-item disabled class="notification-empty">
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<mat-icon class="text-2xl">notifications_none</mat-icon>
|
||||
<p class="mt-1 text-sm">No notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@for (notification of notifications.slice(0, 5); track notification.id) {
|
||||
<div
|
||||
mat-menu-item
|
||||
class="notification-item"
|
||||
[class.unread]="!notification.read"
|
||||
(click)="markAsRead(notification)">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<mat-icon [class]="getNotificationColor(notification.type)" class="mt-1">
|
||||
{{ getNotificationIcon(notification.type) }}
|
||||
</mat-icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-sm truncate">{{ notification.title }}</p>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="clearNotification(notification); $event.stopPropagation()"
|
||||
class="text-gray-400 hover:text-gray-600 ml-2">
|
||||
<mat-icon class="text-lg">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-600 text-xs mt-1 line-clamp-2">{{ notification.message }}</p>
|
||||
<p class="text-gray-400 text-xs mt-1">{{ formatTime(notification.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (!$last) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
|
||||
@if (notifications.length > 5) {
|
||||
<mat-divider></mat-divider>
|
||||
<div mat-menu-item disabled class="text-center text-sm text-gray-500">
|
||||
{{ notifications.length - 5 }} more notifications...
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { Notification, NotificationService } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
MatMenuModule,
|
||||
MatListModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
templateUrl: './notifications.html',
|
||||
styleUrl: './notifications.css',
|
||||
})
|
||||
export class NotificationsComponent {
|
||||
private notificationService = inject(NotificationService);
|
||||
|
||||
get notifications() {
|
||||
return this.notificationService.notifications();
|
||||
}
|
||||
|
||||
get unreadCount() {
|
||||
return this.notificationService.unreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notification: Notification) {
|
||||
this.notificationService.markAsRead(notification.id);
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
this.notificationService.markAllAsRead();
|
||||
}
|
||||
|
||||
clearNotification(notification: Notification) {
|
||||
this.notificationService.clearNotification(notification.id);
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.notificationService.clearAllNotifications();
|
||||
}
|
||||
|
||||
getNotificationIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'success':
|
||||
return 'check_circle';
|
||||
case 'info':
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getNotificationColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warning':
|
||||
return 'text-yellow-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(timestamp: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - timestamp.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'Just now';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/* Sidebar specific styles */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16rem; /* 256px */
|
||||
height: 100vh;
|
||||
background-color: white;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.nav-button.bg-blue-50 {
|
||||
background-color: #eff6ff !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar" [class.sidebar-closed]="!opened()">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">
|
||||
📈 Trading Bot
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Real-time Dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="p-6">
|
||||
<div class="space-y-2">
|
||||
@for (item of navigationItems; track item.route) {
|
||||
<button
|
||||
mat-button
|
||||
class="nav-button"
|
||||
[class.bg-blue-50]="item.active"
|
||||
[class.text-blue-700]="item.active"
|
||||
[class.text-gray-600]="!item.active"
|
||||
(click)="onNavigationClick(item.route)">
|
||||
<mat-icon class="mr-3">{{ item.icon }}</mat-icon>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatSidenavModule, MatButtonModule, MatIconModule],
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrl: './sidebar.component.css',
|
||||
})
|
||||
export class SidebarComponent {
|
||||
opened = input<boolean>(true);
|
||||
navigationItemClick = output<string>();
|
||||
|
||||
protected navigationItems: NavigationItem[] = [
|
||||
{ label: 'Dashboard', icon: 'dashboard', route: '/dashboard', active: true },
|
||||
{ label: 'Market Data', icon: 'trending_up', route: '/market-data' },
|
||||
{ label: 'Exchanges', icon: 'account_balance', route: '/exchanges' },
|
||||
{ label: 'Portfolio', icon: 'account_balance_wallet', route: '/portfolio' },
|
||||
{ label: 'Strategies', icon: 'psychology', route: '/strategies' },
|
||||
{ label: 'Risk Management', icon: 'security', route: '/risk-management' },
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' },
|
||||
];
|
||||
|
||||
constructor(private router: Router) {
|
||||
// Listen to route changes to update active state
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateActiveRoute(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
onNavigationClick(route: string) {
|
||||
this.navigationItemClick.emit(route);
|
||||
this.router.navigate([route]);
|
||||
this.updateActiveRoute(route);
|
||||
}
|
||||
|
||||
private updateActiveRoute(currentRoute: string) {
|
||||
this.navigationItems.forEach(item => {
|
||||
item.active = item.route === currentRoute;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/* Dashboard specific styles */
|
||||
.portfolio-card {
|
||||
background-color: white !important;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-change-positive {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-change-negative {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-chip-active {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-chip-medium {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<!-- Portfolio Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<!-- Total Portfolio Value -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Portfolio Value</p>
|
||||
<p class="metric-value text-gray-900">
|
||||
${{ portfolioValue().toLocaleString('en-US', {minimumFractionDigits: 2}) }}
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Day Change -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Day Change</p>
|
||||
<p class="metric-value"
|
||||
[class.metric-change-positive]="dayChange() > 0"
|
||||
[class.metric-change-negative]="dayChange() < 0">
|
||||
${{ dayChange().toLocaleString('en-US', {minimumFractionDigits: 2}) }}
|
||||
</p>
|
||||
<p class="text-sm font-medium"
|
||||
[class.metric-change-positive]="dayChangePercent() > 0"
|
||||
[class.metric-change-negative]="dayChangePercent() < 0">
|
||||
{{ dayChangePercent() > 0 ? '+' : '' }}{{ dayChangePercent().toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon
|
||||
class="text-3xl"
|
||||
[class.metric-change-positive]="dayChange() > 0"
|
||||
[class.metric-change-negative]="dayChange() < 0">
|
||||
{{ dayChange() > 0 ? 'trending_up' : 'trending_down' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Active Strategies -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Active Strategies</p>
|
||||
<p class="metric-value text-gray-900">3</p>
|
||||
<span class="status-chip-active">Running</span>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">psychology</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Risk Level -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Risk Level</p>
|
||||
<p class="metric-value text-green-600">Low</p>
|
||||
<span class="status-chip-medium">Moderate</span>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl">security</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Market Data Table -->
|
||||
<mat-card class="p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Market Watchlist</h3>
|
||||
<button mat-raised-button color="primary">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="marketData()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let stock" class="font-semibold text-gray-900">{{ stock.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Price Column -->
|
||||
<ng-container matColumnDef="price">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Price</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right font-medium text-gray-900">
|
||||
${{ stock.price.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Column -->
|
||||
<ng-container matColumnDef="change">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.change > 0"
|
||||
[class.text-red-600]="stock.change < 0">
|
||||
{{ stock.change > 0 ? '+' : '' }}${{ stock.change.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Percent Column -->
|
||||
<ng-container matColumnDef="changePercent">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change %</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.changePercent > 0"
|
||||
[class.text-red-600]="stock.changePercent < 0">
|
||||
{{ stock.changePercent > 0 ? '+' : '' }}{{ stock.changePercent.toFixed(2) }}%
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Tabs for Additional Content -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Chart">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">show_chart</mat-icon>
|
||||
<p class="mb-4">Chart visualization will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Orders">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">receipt_long</mat-icon>
|
||||
<p class="mb-4">Order history and management will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Analytics">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">analytics</mat-icon>
|
||||
<p class="mb-4">Advanced analytics and performance metrics will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
|
||||
export interface MarketDataItem {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatTabsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.css',
|
||||
})
|
||||
export class DashboardComponent {
|
||||
// Mock data for the dashboard
|
||||
protected marketData = signal<MarketDataItem[]>([
|
||||
{ symbol: 'AAPL', price: 192.53, change: 2.41, changePercent: 1.27 },
|
||||
{ symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.3 },
|
||||
{ symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.1 },
|
||||
{ symbol: 'TSLA', price: 248.42, change: -3.21, changePercent: -1.28 },
|
||||
]);
|
||||
|
||||
protected portfolioValue = signal(125420.5);
|
||||
protected dayChange = signal(2341.2);
|
||||
protected dayChangePercent = signal(1.9);
|
||||
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent'];
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
.dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 400px;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.dialog-description {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 1rem 0 0 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Form field spacing */
|
||||
mat-form-field + mat-form-field {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Custom validation styles */
|
||||
.mat-mdc-form-field.mat-form-field-invalid .mat-mdc-text-field-wrapper {
|
||||
background-color: rgba(244, 67, 54, 0.04);
|
||||
}
|
||||
|
||||
/* Dialog title styling */
|
||||
h2[mat-dialog-title] {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<h2 mat-dialog-title>Add Source Mapping</h2>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<mat-dialog-content>
|
||||
<div class="dialog-content">
|
||||
<p class="dialog-description">
|
||||
Add a source mapping for exchange: <strong>{{ data.masterExchangeId }}</strong>
|
||||
</p>
|
||||
|
||||
<!-- Source Selection -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Data Source</mat-label>
|
||||
<mat-select formControlName="sourceName" required>
|
||||
@for (source of availableSourcesFiltered; track source.value) {
|
||||
<mat-option [value]="source.value">{{ source.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Please select a data source</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Custom Source Name (shown when 'custom' is selected) -->
|
||||
@if (isCustomSource) {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Custom Source Name</mat-label>
|
||||
<input matInput formControlName="sourceName_custom" required />
|
||||
<mat-error>Please enter a custom source name</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Source ID -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Source Exchange ID</mat-label>
|
||||
<input matInput formControlName="sourceId" required />
|
||||
<mat-hint>The unique identifier for this exchange in the source system</mat-hint>
|
||||
<mat-error>Please enter the source exchange ID</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Optional Name -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Display Name (Optional)</mat-label>
|
||||
<input matInput formControlName="name" />
|
||||
<mat-hint>Human-readable name for this exchange mapping</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Optional Description -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description (Optional)</mat-label>
|
||||
<textarea matInput formControlName="description" rows="3"></textarea>
|
||||
<mat-hint>Additional details about this exchange mapping</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="!form.valid">
|
||||
Add Mapping
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
export interface AddMappingDialogData {
|
||||
masterExchangeId: string;
|
||||
existingSources: string[];
|
||||
}
|
||||
|
||||
export interface AddMappingResult {
|
||||
sourceName: string;
|
||||
sourceData: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-mapping-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './add-mapping-dialog.component.html',
|
||||
styleUrl: './add-mapping-dialog.component.css',
|
||||
})
|
||||
export class AddMappingDialogComponent {
|
||||
form: FormGroup;
|
||||
availableSources = [
|
||||
{ value: 'ib', label: 'Interactive Brokers' },
|
||||
{ value: 'yahoo', label: 'Yahoo Finance' },
|
||||
{ value: 'alpha', label: 'Alpha Vantage' },
|
||||
{ value: 'polygon', label: 'Polygon.io' },
|
||||
{ value: 'finnhub', label: 'Finnhub' },
|
||||
{ value: 'custom', label: 'Custom Source' },
|
||||
];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private dialogRef: MatDialogRef<AddMappingDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AddMappingDialogData
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
sourceName: ['', [Validators.required]],
|
||||
sourceId: ['', [Validators.required]],
|
||||
sourceName_custom: [''],
|
||||
name: [''],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
// Add custom validator for custom source name
|
||||
this.form.get('sourceName')?.valueChanges.subscribe(value => {
|
||||
const customNameControl = this.form.get('sourceName_custom');
|
||||
if (value === 'custom') {
|
||||
customNameControl?.setValidators([Validators.required]);
|
||||
} else {
|
||||
customNameControl?.clearValidators();
|
||||
}
|
||||
customNameControl?.updateValueAndValidity();
|
||||
});
|
||||
}
|
||||
|
||||
get availableSourcesFiltered() {
|
||||
return this.availableSources.filter(
|
||||
source => !this.data.existingSources.includes(source.value)
|
||||
);
|
||||
}
|
||||
|
||||
get isCustomSource() {
|
||||
return this.form.get('sourceName')?.value === 'custom';
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.form.valid) {
|
||||
let sourceName = this.form.get('sourceName')?.value;
|
||||
|
||||
// If custom source, use the custom name
|
||||
if (sourceName === 'custom') {
|
||||
sourceName = this.form.get('sourceName_custom')?.value;
|
||||
|
||||
// Validate custom source name
|
||||
if (!sourceName || sourceName.trim() === '') {
|
||||
this.form.get('sourceName_custom')?.setErrors({ required: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result: AddMappingResult = {
|
||||
sourceName,
|
||||
sourceData: {
|
||||
id: this.form.get('sourceId')?.value,
|
||||
name: this.form.get('name')?.value || undefined,
|
||||
description: this.form.get('description')?.value || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
.exchanges-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.exchanges-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.exchanges-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exchange-id {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.source-mappings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.no-mappings {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.source-id {
|
||||
color: #6b7280;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: #d1d5db;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.exchanges-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.exchanges-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
<!-- Exchanges Page -->
|
||||
<div class="exchanges-container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Exchange Management</h1>
|
||||
<p class="page-description">
|
||||
Manage master exchanges and their source mappings across different data providers.
|
||||
</p>
|
||||
<div class="page-actions">
|
||||
<button mat-raised-button color="primary" (click)="syncExchanges()">
|
||||
<mat-icon>sync</mat-icon>
|
||||
Sync Exchanges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div *ngIf="loading" class="loading-container">
|
||||
<mat-progress-spinner></mat-progress-spinner>
|
||||
<p>Loading exchanges...</p>
|
||||
</div>
|
||||
|
||||
<!-- Exchanges Table -->
|
||||
<mat-card *ngIf="!loading" class="exchanges-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Master Exchanges ({{ exchanges.length }})</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<table mat-table [dataSource]="exchanges" class="exchanges-table">
|
||||
<!-- Exchange ID Column -->
|
||||
<ng-container matColumnDef="masterExchangeId">
|
||||
<th mat-header-cell *matHeaderCellDef>Exchange ID</th>
|
||||
<td mat-cell *matCellDef="let exchange">
|
||||
<span class="exchange-id">{{ exchange.masterExchangeId }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Source Mappings Column -->
|
||||
<ng-container matColumnDef="sourceMappings">
|
||||
<th mat-header-cell *matHeaderCellDef>Source Mappings</th>
|
||||
<td mat-cell *matCellDef="let exchange">
|
||||
<div class="source-mappings">
|
||||
@if (getSourceMappingKeys(exchange.sourceMappings).length === 0) {
|
||||
<span class="no-mappings">No mappings</span>
|
||||
} @else {
|
||||
<mat-chip-set>
|
||||
@for (source of getSourceMappingKeys(exchange.sourceMappings); track source) {
|
||||
<mat-chip-option>
|
||||
<span class="source-name">{{ source }}</span>
|
||||
<span class="source-id">{{ exchange.sourceMappings[source]?.id }}</span>
|
||||
</mat-chip-option>
|
||||
}
|
||||
</mat-chip-set>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let exchange">
|
||||
<button mat-button color="primary" (click)="openAddMappingDialog(exchange)">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Mapping
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
@if (exchanges.length === 0 && !loading) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">account_balance</mat-icon>
|
||||
<h3>No exchanges found</h3>
|
||||
<p>Start by syncing exchanges from your data providers.</p>
|
||||
<button mat-raised-button color="primary" (click)="syncExchanges()">
|
||||
<mat-icon>sync</mat-icon>
|
||||
Sync Exchanges
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { ExchangeService } from '../../services/exchange.service';
|
||||
import { AddMappingDialogComponent } from './dialogs/add-mapping-dialog.component';
|
||||
|
||||
export interface SourceMapping {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MasterExchange {
|
||||
masterExchangeId: string;
|
||||
sourceMappings: Record<string, SourceMapping>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exchanges',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
templateUrl: './exchanges.component.html',
|
||||
styleUrl: './exchanges.component.css',
|
||||
})
|
||||
export class ExchangesComponent implements OnInit {
|
||||
exchanges: MasterExchange[] = [];
|
||||
loading = false;
|
||||
displayedColumns: string[] = ['masterExchangeId', 'sourceMappings', 'actions'];
|
||||
|
||||
constructor(
|
||||
private exchangeService: ExchangeService,
|
||||
private dialog: MatDialog,
|
||||
private snackBar: MatSnackBar
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadExchanges();
|
||||
}
|
||||
|
||||
async loadExchanges() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await this.exchangeService.getAllExchanges();
|
||||
this.exchanges = response.data;
|
||||
} catch {
|
||||
this.snackBar.open('Error loading exchanges', 'Close', { duration: 3000 });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getSourceMappingKeys(sourceMappings: Record<string, SourceMapping>): string[] {
|
||||
return Object.keys(sourceMappings || {});
|
||||
}
|
||||
|
||||
openAddMappingDialog(exchange: MasterExchange) {
|
||||
const dialogRef = this.dialog.open(AddMappingDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
masterExchangeId: exchange.masterExchangeId,
|
||||
existingSources: this.getSourceMappingKeys(exchange.sourceMappings),
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.addSourceMapping(exchange.masterExchangeId, result.sourceName, result.sourceData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async addSourceMapping(masterExchangeId: string, sourceName: string, sourceData: SourceMapping) {
|
||||
try {
|
||||
await this.exchangeService.addSourceMapping(masterExchangeId, sourceName, sourceData);
|
||||
this.snackBar.open('Source mapping added successfully', 'Close', { duration: 3000 });
|
||||
this.loadExchanges(); // Refresh the list
|
||||
} catch {
|
||||
this.snackBar.open('Error adding source mapping', 'Close', { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
async syncExchanges() {
|
||||
try {
|
||||
await this.exchangeService.syncExchanges();
|
||||
this.snackBar.open('Exchange sync initiated', 'Close', { duration: 3000 });
|
||||
} catch {
|
||||
this.snackBar.open('Error syncing exchanges', 'Close', { duration: 3000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
/* Market Data specific styles */
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
<div class="space-y-6"> <!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Market Data</h1>
|
||||
<p class="text-gray-600 mt-1">Real-time market information and analytics</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Market Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Market Status</p>
|
||||
<p class="text-lg font-semibold text-green-600">Open</p>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl">schedule</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Active Instruments</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ marketData().length }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">trending_up</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between"> <div>
|
||||
<p class="text-sm text-gray-600">Last Update</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ currentTime() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">access_time</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Market Data Table -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Live Market Data</h3>
|
||||
<div class="flex gap-2">
|
||||
<button mat-button>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Filter
|
||||
</button>
|
||||
<button mat-button>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading market data...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="marketData()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let stock" class="font-semibold text-gray-900">{{ stock.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Price Column -->
|
||||
<ng-container matColumnDef="price">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Price</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right font-medium text-gray-900">
|
||||
${{ stock.price.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Column -->
|
||||
<ng-container matColumnDef="change">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.change > 0"
|
||||
[class.text-red-600]="stock.change < 0">
|
||||
{{ stock.change > 0 ? '+' : '' }}${{ stock.change.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Percent Column -->
|
||||
<ng-container matColumnDef="changePercent">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change %</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.changePercent > 0"
|
||||
[class.text-red-600]="stock.changePercent < 0">
|
||||
{{ stock.changePercent > 0 ? '+' : '' }}{{ stock.changePercent.toFixed(2) }}%
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Volume Column -->
|
||||
<ng-container matColumnDef="volume">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Volume</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right text-gray-600">
|
||||
{{ stock.volume.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Market Cap Column -->
|
||||
<ng-container matColumnDef="marketCap">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Market Cap</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right text-gray-600">
|
||||
${{ stock.marketCap }}
|
||||
</td>
|
||||
</ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
|
||||
<!-- Market Analytics Tabs -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Technical Analysis">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">bar_chart</mat-icon>
|
||||
<p class="mb-4">Technical analysis charts and indicators will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Market Trends">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">timeline</mat-icon>
|
||||
<p class="mb-4">Market trends and sector analysis will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="News & Events">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">article</mat-icon>
|
||||
<p class="mb-4">Market news and economic events will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
|
||||
export interface ExtendedMarketData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
marketCap: string;
|
||||
high52Week: number;
|
||||
low52Week: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-market-data',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
templateUrl: './market-data.component.html',
|
||||
styleUrl: './market-data.component.css',
|
||||
})
|
||||
export class MarketDataComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected marketData = signal<ExtendedMarketData[]>([]);
|
||||
protected currentTime = signal<string>(new Date().toLocaleTimeString());
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns: string[] = [
|
||||
'symbol',
|
||||
'price',
|
||||
'change',
|
||||
'changePercent',
|
||||
'volume',
|
||||
'marketCap',
|
||||
];
|
||||
ngOnInit() {
|
||||
// Update time every second
|
||||
const timeSubscription = interval(1000).subscribe(() => {
|
||||
this.currentTime.set(new Date().toLocaleTimeString());
|
||||
});
|
||||
this.subscriptions.push(timeSubscription);
|
||||
|
||||
// Load initial market data
|
||||
this.loadMarketData();
|
||||
|
||||
// Subscribe to real-time market data updates
|
||||
const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({
|
||||
next: update => {
|
||||
this.updateMarketData(update);
|
||||
},
|
||||
error: err => {
|
||||
console.error('WebSocket market data error:', err);
|
||||
},
|
||||
});
|
||||
this.subscriptions.push(wsSubscription);
|
||||
|
||||
// Fallback: Refresh market data every 30 seconds if WebSocket fails
|
||||
const dataSubscription = interval(30000).subscribe(() => {
|
||||
if (!this.webSocketService.isConnected()) {
|
||||
this.loadMarketData();
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(dataSubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
private loadMarketData() {
|
||||
this.apiService.getMarketData().subscribe({
|
||||
next: response => {
|
||||
// Convert MarketData to ExtendedMarketData with mock extended properties
|
||||
const extendedData: ExtendedMarketData[] = response.data.map(item => ({
|
||||
...item,
|
||||
marketCap: this.getMockMarketCap(item.symbol),
|
||||
high52Week: item.price * 1.3, // Mock 52-week high (30% above current)
|
||||
low52Week: item.price * 0.7, // Mock 52-week low (30% below current)
|
||||
}));
|
||||
|
||||
this.marketData.set(extendedData);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: err => {
|
||||
console.error('Failed to load market data:', err);
|
||||
this.error.set('Failed to load market data');
|
||||
this.isLoading.set(false);
|
||||
this.snackBar.open('Failed to load market data', 'Dismiss', { duration: 5000 });
|
||||
|
||||
// Use mock data as fallback
|
||||
this.marketData.set(this.getMockData());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getMockMarketCap(symbol: string): string {
|
||||
const marketCaps: { [key: string]: string } = {
|
||||
AAPL: '2.98T',
|
||||
GOOGL: '1.78T',
|
||||
MSFT: '3.08T',
|
||||
TSLA: '789.2B',
|
||||
AMZN: '1.59T',
|
||||
};
|
||||
return marketCaps[symbol] || '1.00T';
|
||||
}
|
||||
|
||||
private getMockData(): ExtendedMarketData[] {
|
||||
return [
|
||||
{
|
||||
symbol: 'AAPL',
|
||||
price: 192.53,
|
||||
change: 2.41,
|
||||
changePercent: 1.27,
|
||||
volume: 45230000,
|
||||
marketCap: '2.98T',
|
||||
high52Week: 199.62,
|
||||
low52Week: 164.08,
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
price: 2847.56,
|
||||
change: -12.34,
|
||||
changePercent: -0.43,
|
||||
volume: 12450000,
|
||||
marketCap: '1.78T',
|
||||
high52Week: 3030.93,
|
||||
low52Week: 2193.62,
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
price: 415.26,
|
||||
change: 8.73,
|
||||
changePercent: 2.15,
|
||||
volume: 23180000,
|
||||
marketCap: '3.08T',
|
||||
high52Week: 468.35,
|
||||
low52Week: 309.45,
|
||||
},
|
||||
{
|
||||
symbol: 'TSLA',
|
||||
price: 248.5,
|
||||
change: -5.21,
|
||||
changePercent: -2.05,
|
||||
volume: 89760000,
|
||||
marketCap: '789.2B',
|
||||
high52Week: 299.29,
|
||||
low52Week: 152.37,
|
||||
},
|
||||
{
|
||||
symbol: 'AMZN',
|
||||
price: 152.74,
|
||||
change: 3.18,
|
||||
changePercent: 2.12,
|
||||
volume: 34520000,
|
||||
marketCap: '1.59T',
|
||||
high52Week: 170.17,
|
||||
low52Week: 118.35,
|
||||
},
|
||||
];
|
||||
}
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadMarketData();
|
||||
}
|
||||
|
||||
private updateMarketData(update: any) {
|
||||
const currentData = this.marketData();
|
||||
const updatedData = currentData.map(item => {
|
||||
if (item.symbol === update.symbol) {
|
||||
return {
|
||||
...item,
|
||||
price: update.price,
|
||||
change: update.change,
|
||||
changePercent: update.changePercent,
|
||||
volume: update.volume,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.marketData.set(updatedData);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
/* Settings specific styles */
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p class="text-gray-600 mt-1">Configure application preferences and system settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">settings</mat-icon>
|
||||
<p class="mb-4">Application settings and configuration will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.css',
|
||||
})
|
||||
export class SettingsComponent {}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface RiskThresholds {
|
||||
maxPositionSize: number;
|
||||
maxDailyLoss: number;
|
||||
maxPortfolioRisk: number;
|
||||
volatilityLimit: number;
|
||||
}
|
||||
|
||||
export interface RiskEvaluation {
|
||||
symbol: string;
|
||||
positionValue: number;
|
||||
positionRisk: number;
|
||||
violations: string[];
|
||||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
}
|
||||
|
||||
export interface MarketData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrls = {
|
||||
riskGuardian: 'http://localhost:3002',
|
||||
strategyOrchestrator: 'http://localhost:3003',
|
||||
marketDataGateway: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Risk Guardian API
|
||||
getRiskThresholds(): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.get<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`
|
||||
);
|
||||
}
|
||||
|
||||
updateRiskThresholds(
|
||||
thresholds: RiskThresholds
|
||||
): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.put<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`,
|
||||
thresholds
|
||||
);
|
||||
}
|
||||
|
||||
evaluateRisk(params: {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
portfolioValue: number;
|
||||
}): Observable<{ success: boolean; data: RiskEvaluation }> {
|
||||
return this.http.post<{ success: boolean; data: RiskEvaluation }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/evaluate`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
getRiskHistory(): Observable<{ success: boolean; data: RiskEvaluation[] }> {
|
||||
return this.http.get<{ success: boolean; data: RiskEvaluation[] }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/history`
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Orchestrator API
|
||||
getStrategies(): Observable<{ success: boolean; data: any[] }> {
|
||||
return this.http.get<{ success: boolean; data: any[] }>(
|
||||
`${this.baseUrls.strategyOrchestrator}/api/strategies`
|
||||
);
|
||||
}
|
||||
|
||||
createStrategy(strategy: any): Observable<{ success: boolean; data: any }> {
|
||||
return this.http.post<{ success: boolean; data: any }>(
|
||||
`${this.baseUrls.strategyOrchestrator}/api/strategies`,
|
||||
strategy
|
||||
);
|
||||
}
|
||||
// Market Data Gateway API
|
||||
getMarketData(
|
||||
symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']
|
||||
): Observable<{ success: boolean; data: MarketData[] }> {
|
||||
const symbolsParam = symbols.join(',');
|
||||
return this.http.get<{ success: boolean; data: MarketData[] }>(
|
||||
`${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}`
|
||||
);
|
||||
}
|
||||
|
||||
// Health checks
|
||||
checkServiceHealth(
|
||||
service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'
|
||||
): Observable<any> {
|
||||
return this.http.get(`${this.baseUrls[service]}/health`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BreadcrumbService {
|
||||
private breadcrumbsSubject = new BehaviorSubject<BreadcrumbItem[]>([]);
|
||||
public breadcrumbs$ = this.breadcrumbsSubject.asObservable();
|
||||
|
||||
private routeLabels: Record<string, string> = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/market-data': 'Market Data',
|
||||
'/exchanges': 'Exchanges',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
constructor(private router: Router) {
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateBreadcrumbs(event.url);
|
||||
});
|
||||
}
|
||||
|
||||
private updateBreadcrumbs(url: string) {
|
||||
const pathSegments = url.split('/').filter(segment => segment);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
// Always add home/dashboard as first breadcrumb
|
||||
breadcrumbs.push({
|
||||
label: 'Home',
|
||||
url: '/dashboard',
|
||||
isActive: url === '/dashboard' || url === '/',
|
||||
});
|
||||
|
||||
// Build breadcrumbs for current path
|
||||
let currentPath = '';
|
||||
pathSegments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
|
||||
// Skip if it's the dashboard route (already added as Home)
|
||||
if (currentPath === '/dashboard') {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.getRouteLabel(currentPath, segment);
|
||||
breadcrumbs.push({
|
||||
label,
|
||||
url: currentPath,
|
||||
isActive: isLast,
|
||||
});
|
||||
});
|
||||
|
||||
this.breadcrumbsSubject.next(breadcrumbs);
|
||||
}
|
||||
|
||||
private getRouteLabel(path: string, segment: string): string {
|
||||
// Check if we have a custom label for this path
|
||||
if (this.routeLabels[path]) {
|
||||
return this.routeLabels[path];
|
||||
}
|
||||
|
||||
// Convert kebab-case to Title Case
|
||||
return segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
public getCurrentPageTitle(): string {
|
||||
const breadcrumbs = this.breadcrumbsSubject.value;
|
||||
const activeBreadcrumb = breadcrumbs.find(item => item.isActive);
|
||||
return activeBreadcrumb?.label || 'Dashboard';
|
||||
}
|
||||
|
||||
public addRouteLabel(path: string, label: string) {
|
||||
this.routeLabels[path] = label;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface SourceMapping {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExchangeResponse {
|
||||
data: Array<{
|
||||
masterExchangeId: string;
|
||||
sourceMappings: Record<string, SourceMapping>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}>;
|
||||
count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AddMappingResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
data: {
|
||||
masterExchangeId: string;
|
||||
sourceName: string;
|
||||
sourceData: SourceMapping;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
jobId: string;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
export interface GetExchangeResponse {
|
||||
status: string;
|
||||
data: {
|
||||
masterExchangeId: string;
|
||||
sourceMappings: Record<string, SourceMapping>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetExchangesBySourceResponse {
|
||||
data: Array<{
|
||||
masterExchangeId: string;
|
||||
sourceMapping: SourceMapping;
|
||||
}>;
|
||||
count: number;
|
||||
status: string;
|
||||
sourceName: string;
|
||||
}
|
||||
|
||||
export interface GetSourceMappingResponse {
|
||||
status: string;
|
||||
data: {
|
||||
masterExchangeId: string;
|
||||
sourceName: string;
|
||||
sourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ExchangeService {
|
||||
private readonly baseUrl = 'http://localhost:2001/api/exchanges';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
async getAllExchanges(): Promise<ExchangeResponse> {
|
||||
return this.http.get<ExchangeResponse>(this.baseUrl).toPromise() as Promise<ExchangeResponse>;
|
||||
}
|
||||
|
||||
async getExchange(masterExchangeId: string): Promise<GetExchangeResponse> {
|
||||
return this.http
|
||||
.get<GetExchangeResponse>(`${this.baseUrl}/${masterExchangeId}`)
|
||||
.toPromise() as Promise<GetExchangeResponse>;
|
||||
}
|
||||
|
||||
async addSourceMapping(
|
||||
masterExchangeId: string,
|
||||
sourceName: string,
|
||||
sourceData: SourceMapping
|
||||
): Promise<AddMappingResponse> {
|
||||
return this.http
|
||||
.post<AddMappingResponse>(`${this.baseUrl}/${masterExchangeId}/mappings`, {
|
||||
sourceName,
|
||||
sourceData,
|
||||
})
|
||||
.toPromise() as Promise<AddMappingResponse>;
|
||||
}
|
||||
|
||||
async syncExchanges(): Promise<SyncResponse> {
|
||||
return this.http
|
||||
.post<SyncResponse>(`${this.baseUrl}/sync`, {})
|
||||
.toPromise() as Promise<SyncResponse>;
|
||||
}
|
||||
|
||||
async getExchangesBySource(sourceName: string): Promise<GetExchangesBySourceResponse> {
|
||||
return this.http
|
||||
.get<GetExchangesBySourceResponse>(`${this.baseUrl}/source/${sourceName}`)
|
||||
.toPromise() as Promise<GetExchangesBySourceResponse>;
|
||||
}
|
||||
|
||||
async getSourceMapping(sourceName: string, sourceId: string): Promise<GetSourceMappingResponse> {
|
||||
return this.http
|
||||
.get<GetSourceMappingResponse>(`${this.baseUrl}/mapping/${sourceName}/${sourceId}`)
|
||||
.toPromise() as Promise<GetSourceMappingResponse>;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RiskAlert, WebSocketService } from './websocket.service';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NotificationService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private riskAlertsSubscription?: Subscription;
|
||||
|
||||
// Reactive state
|
||||
public notifications = signal<Notification[]>([]);
|
||||
public unreadCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
this.initializeRiskAlerts();
|
||||
}
|
||||
|
||||
private initializeRiskAlerts() {
|
||||
// Subscribe to risk alerts from WebSocket
|
||||
this.riskAlertsSubscription = this.webSocketService.getRiskAlerts().subscribe({
|
||||
next: (alert: RiskAlert) => {
|
||||
this.handleRiskAlert(alert);
|
||||
},
|
||||
error: err => {
|
||||
console.error('Risk alert subscription error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleRiskAlert(alert: RiskAlert) {
|
||||
const notification: Notification = {
|
||||
id: alert.id,
|
||||
type: this.mapSeverityToType(alert.severity),
|
||||
title: `Risk Alert: ${alert.symbol}`,
|
||||
message: alert.message,
|
||||
timestamp: new Date(alert.timestamp),
|
||||
read: false,
|
||||
};
|
||||
|
||||
this.addNotification(notification);
|
||||
this.showSnackBarAlert(notification);
|
||||
}
|
||||
|
||||
private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' {
|
||||
switch (severity) {
|
||||
case 'HIGH':
|
||||
return 'error';
|
||||
case 'MEDIUM':
|
||||
return 'warning';
|
||||
case 'LOW':
|
||||
return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
private showSnackBarAlert(notification: Notification) {
|
||||
const actionText = notification.type === 'error' ? 'Review' : 'Dismiss';
|
||||
const duration = notification.type === 'error' ? 10000 : 5000;
|
||||
|
||||
this.snackBar.open(`${notification.title}: ${notification.message}`, actionText, {
|
||||
duration,
|
||||
panelClass: [`snack-${notification.type}`],
|
||||
});
|
||||
}
|
||||
|
||||
// Public methods
|
||||
addNotification(notification: Notification) {
|
||||
const current = this.notifications();
|
||||
const updated = [notification, ...current].slice(0, 50); // Keep only latest 50
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n => (n.id === notificationId ? { ...n, read: true } : n));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n => ({ ...n, read: true }));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearNotification(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.filter(n => n.id !== notificationId);
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearAllNotifications() {
|
||||
this.notifications.set([]);
|
||||
this.unreadCount.set(0);
|
||||
}
|
||||
|
||||
private updateUnreadCount() {
|
||||
const unread = this.notifications().filter(n => !n.read).length;
|
||||
this.unreadCount.set(unread);
|
||||
}
|
||||
|
||||
// Manual notification methods
|
||||
showSuccess(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 3000,
|
||||
panelClass: ['snack-success'],
|
||||
});
|
||||
}
|
||||
|
||||
showError(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-error'],
|
||||
});
|
||||
}
|
||||
|
||||
showWarning(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 5000,
|
||||
panelClass: ['snack-warning'],
|
||||
});
|
||||
}
|
||||
|
||||
showInfo(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 4000,
|
||||
panelClass: ['snack-info'],
|
||||
});
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.riskAlertsSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
|
||||
export interface RiskAlert {
|
||||
id: string;
|
||||
symbol: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MarketDataUpdate {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
payload?: MarketDataUpdate | RiskAlert;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebSocketService {
|
||||
private socket?: WebSocket;
|
||||
private connected = new BehaviorSubject<boolean>(false);
|
||||
private marketDataSubject = new Subject<MarketDataUpdate>();
|
||||
private riskAlertSubject = new Subject<RiskAlert>();
|
||||
|
||||
private readonly WS_URL = 'ws://localhost:3001/ws'; // Adjust based on your backend
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
try {
|
||||
this.socket = new WebSocket(this.WS_URL);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.connected.next(true);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
} catch {
|
||||
// Handle parsing error silently
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.connected.next(false);
|
||||
// Reconnect after 5 seconds
|
||||
setTimeout(() => this.connect(), 5000);
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.connected.next(false);
|
||||
};
|
||||
} catch {
|
||||
this.connected.next(false);
|
||||
// Retry connection after 5 seconds
|
||||
setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: WebSocketMessage) {
|
||||
switch (data.type) {
|
||||
case 'marketData':
|
||||
this.marketDataSubject.next(data.payload as MarketDataUpdate);
|
||||
break;
|
||||
case 'riskAlert':
|
||||
this.riskAlertSubject.next(data.payload as RiskAlert);
|
||||
break;
|
||||
default:
|
||||
// Unknown message type - ignore
|
||||
}
|
||||
}
|
||||
|
||||
public getMarketDataUpdates(): Observable<MarketDataUpdate> {
|
||||
return this.marketDataSubject.asObservable();
|
||||
}
|
||||
|
||||
public getRiskAlerts(): Observable<RiskAlert> {
|
||||
return this.riskAlertSubject.asObservable();
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected.value;
|
||||
}
|
||||
|
||||
public getConnectionStatus(): Observable<boolean> {
|
||||
return this.connected.asObservable();
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
public subscribe(channel: string) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
channel: channel
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public unsubscribe(channel: string) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
channel: channel
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Trading Dashboard</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch(err => console.error(err));
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Custom base styles */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Angular Material integration styles */
|
||||
.mat-sidenav-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mat-sidenav {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mat-toolbar {
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.mat-mdc-button.w-full {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mat-mdc-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mat-mdc-chip.chip-green {
|
||||
background-color: #dcfce7 !important;
|
||||
color: #166534 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-chip.chip-blue {
|
||||
background-color: #dbeafe !important;
|
||||
color: #1e40af !important;
|
||||
}
|
||||
|
||||
.mat-mdc-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mat-mdc-header-row {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .mat-toolbar {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-header-row {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-row:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-card {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-table {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,ts}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
950: '#052e16',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
950: '#450a0a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["jasmine"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -19,12 +19,15 @@ loadEnvVariables();
|
|||
const app = new Hono();
|
||||
|
||||
// Add CORS middleware
|
||||
app.use('*', cors({
|
||||
origin: ['http://localhost:4200', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: ['http://localhost:4200', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
const logger = getLogger('data-service');
|
||||
const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002');
|
||||
|
|
|
|||
9
apps/web/.env.example
Normal file
9
apps/web/.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/web/README.md
Normal file
95
apps/web/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.
|
||||
31
apps/web/eslint.config.js
Normal file
31
apps/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import js from '@eslint/js';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
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,
|
||||
parser: tsParser,
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'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 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
15
apps/web/index.html
Normal file
15
apps/web/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>
|
||||
43
apps/web/package.json
Normal file
43
apps/web/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "@stock-bot/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --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-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-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/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
apps/web/public/vite.svg
Normal file
4
apps/web/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/web/src/App.tsx
Normal file
232
apps/web/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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/app/App.tsx
Normal file
10
apps/web/src/app/App.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Layout } from '@/components/layout';
|
||||
import { DashboardPage } from '@/features/dashboard';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Layout title="Dashboard">
|
||||
<DashboardPage />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/app/index.ts
Normal file
1
apps/web/src/app/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { App } from './App';
|
||||
2
apps/web/src/components/index.ts
Normal file
2
apps/web/src/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ui';
|
||||
export * from './layout';
|
||||
22
apps/web/src/components/layout/Header.tsx
Normal file
22
apps/web/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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/components/layout/Layout.tsx
Normal file
23
apps/web/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useState, ReactNode } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Layout({ children, title }: LayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
<Header setSidebarOpen={setSidebarOpen} title={title} />
|
||||
|
||||
<main className="py-4 lg:pl-60 w-full">
|
||||
<div className="px-4">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/components/layout/Sidebar.tsx
Normal file
117
apps/web/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { navigation } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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 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={cn(
|
||||
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={cn(
|
||||
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>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/components/layout/index.ts
Normal file
3
apps/web/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/web/src/components/ui/Card.tsx
Normal file
40
apps/web/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>;
|
||||
}
|
||||
191
apps/web/src/components/ui/DataTable/DataTable.tsx
Normal file
191
apps/web/src/components/ui/DataTable/DataTable.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeGrid as Grid } from 'react-window';
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: ColumnDef<T>[];
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
loading?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
rowHeight = 35,
|
||||
headerHeight = 40,
|
||||
loading = false,
|
||||
onRowClick,
|
||||
className = '',
|
||||
}: DataTableProps<T>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
globalFilter,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableSorting: true,
|
||||
enableGlobalFilter: true,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
const visibleColumns = table.getVisibleFlatColumns();
|
||||
|
||||
// Calculate column widths as numbers for react-window
|
||||
const columnWidths = useMemo(() => {
|
||||
return visibleColumns.map(column => column.getSize());
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Unified cell renderer that handles both header and data rows
|
||||
const Cell = useCallback(
|
||||
({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
}: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const column = visibleColumns[columnIndex];
|
||||
|
||||
// Header row (rowIndex 0) - make it sticky
|
||||
if (rowIndex === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
width: columnWidths[columnIndex],
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 text-xs font-medium text-text-secondary uppercase tracking-wider',
|
||||
'border-r border-b border-border bg-surface hover:bg-surface-secondary',
|
||||
column.getCanSort() && 'cursor-pointer select-none'
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
<span className="truncate">
|
||||
{typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id}
|
||||
</span>
|
||||
|
||||
{column.getCanSort() && (
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
{column.getIsSorted() === 'asc' ? (
|
||||
<ChevronUpIcon className="h-4 w-4 text-primary-400" />
|
||||
) : column.getIsSorted() === 'desc' ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-primary-400" />
|
||||
) : (
|
||||
<div className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data rows (rowIndex > 0)
|
||||
const row = rows[rowIndex - 1]; // Subtract 1 because row 0 is header
|
||||
const cell = row?.getVisibleCells()[columnIndex];
|
||||
|
||||
if (!cell || !column) {
|
||||
return <div style={style} className="border-r border-border bg-background" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
width: columnWidths[columnIndex],
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-sm text-text-primary border-r border-border bg-background',
|
||||
'hover:bg-surface cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
<div className="truncate w-full">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[rows, visibleColumns, onRowClick, columnWidths]
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('bg-background text-text-primary w-full h-full flex flex-col', className)}>
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={e => setGlobalFilter(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full max-w-md bg-surface border border-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Virtual Grid with AutoSizer - includes sticky header */}
|
||||
<div className="flex-1 border border-border rounded-lg overflow-hidden">
|
||||
<AutoSizer>
|
||||
{({ height: autoHeight, width: autoWidth }) => (
|
||||
<Grid
|
||||
height={autoHeight}
|
||||
width={autoWidth}
|
||||
rowCount={rows.length + 1} // +1 for header row
|
||||
columnCount={visibleColumns.length}
|
||||
rowHeight={index => (index === 0 ? headerHeight : rowHeight)} // Header height for row 0
|
||||
columnWidth={index => columnWidths[index] || 150}
|
||||
overscanRowCount={5}
|
||||
overscanColumnCount={2}
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-2 px-2 text-sm text-text-secondary flex-shrink-0">
|
||||
<span>
|
||||
Showing {rows.length} of {data.length} rows
|
||||
</span>
|
||||
{globalFilter && <span>Filtered by: "{globalFilter}"</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/ui/DataTable/ExampleTable.tsx
Normal file
89
apps/web/src/components/ui/DataTable/ExampleTable.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { DataTable } from '@/components/ui';
|
||||
|
||||
interface SampleData {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'active' | 'inactive';
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function ExampleTable() {
|
||||
// Sample data
|
||||
const data: SampleData[] = [
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'active', value: 100 },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'inactive', value: 250 },
|
||||
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'active', value: 150 },
|
||||
// Add more sample data...
|
||||
...Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 4,
|
||||
name: `User ${i + 4}`,
|
||||
email: `user${i + 4}@example.com`,
|
||||
status: Math.random() > 0.5 ? 'active' : ('inactive' as const),
|
||||
value: Math.floor(Math.random() * 1000),
|
||||
})),
|
||||
];
|
||||
|
||||
// Define columns
|
||||
const columns: ColumnDef<SampleData>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue() as string;
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
status === 'active' ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
accessorKey: 'value',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-mono">${(getValue() as number).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-4">Example Data Table</h2>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={500}
|
||||
onRowClick={row => console.log('Clicked row:', row)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
apps/web/src/components/ui/DataTable/TableControls.tsx
Normal file
145
apps/web/src/components/ui/DataTable/TableControls.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { MagnifyingGlassIcon, FunnelIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
import { TableColumn } from './types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TableControlsProps<T> {
|
||||
table: Table<T>;
|
||||
globalFilter: string;
|
||||
setGlobalFilter: (value: string) => void;
|
||||
columns: TableColumn<T>[];
|
||||
onGroupingChange: (grouping: string[]) => void;
|
||||
}
|
||||
|
||||
export function TableControls<T>({
|
||||
table,
|
||||
globalFilter,
|
||||
setGlobalFilter,
|
||||
columns,
|
||||
onGroupingChange,
|
||||
}: TableControlsProps<T>) {
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [selectedGroupColumn, setSelectedGroupColumn] = useState('');
|
||||
|
||||
const groupableColumns = columns.filter(col => col.groupable);
|
||||
const currentGrouping = table.getState().grouping;
|
||||
|
||||
const handleGroupingChange = (columnId: string) => {
|
||||
if (columnId === '') {
|
||||
onGroupingChange([]);
|
||||
} else {
|
||||
onGroupingChange([columnId]);
|
||||
}
|
||||
setSelectedGroupColumn(columnId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background border-b border-border p-4 space-y-4">
|
||||
{/* Main Controls Row */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Global Search */}
|
||||
<div className="flex items-center space-x-2 flex-1 max-w-md">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={e => setGlobalFilter(e.target.value)}
|
||||
placeholder="Search all columns..."
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Grouping */}
|
||||
{groupableColumns.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Squares2X2Icon className="h-4 w-4 text-text-muted" />
|
||||
<select
|
||||
value={selectedGroupColumn}
|
||||
onChange={e => handleGroupingChange(e.target.value)}
|
||||
className="bg-surface border border-border rounded px-2 py-1 text-sm text-text-primary"
|
||||
>
|
||||
<option value="">No grouping</option>
|
||||
{groupableColumns.map(column => (
|
||||
<option key={column.id} value={column.id}>
|
||||
Group by {column.header}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<button
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className={cn(
|
||||
'flex items-center space-x-1 px-3 py-2 rounded text-sm font-medium transition-colors',
|
||||
showAdvancedFilters
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:bg-surface-secondary'
|
||||
)}
|
||||
>
|
||||
<FunnelIcon className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
</button>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setGlobalFilter('');
|
||||
table.resetSorting();
|
||||
table.resetColumnFilters();
|
||||
table.resetGrouping();
|
||||
setSelectedGroupColumn('');
|
||||
}}
|
||||
className="px-3 py-2 bg-surface border border-border rounded text-sm text-text-secondary hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters Panel */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<h4 className="text-sm font-medium text-text-primary mb-3">Column Filters</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanFilter())
|
||||
.map(column => (
|
||||
<div key={column.id} className="space-y-1">
|
||||
<label className="text-xs text-text-secondary">
|
||||
{typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: column.id}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(column.getFilterValue() as string) || ''}
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
placeholder={`Filter ${column.id}...`}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-xs text-text-primary placeholder-text-muted focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Info */}
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<div className="flex items-center space-x-4">
|
||||
{globalFilter && <span>Global filter: "{globalFilter}"</span>}
|
||||
{currentGrouping.length > 0 && <span>Grouped by: {currentGrouping.join(', ')}</span>}
|
||||
{table.getState().columnFilters.length > 0 && (
|
||||
<span>{table.getState().columnFilters.length} column filters active</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{table.getPreFilteredRowModel().rows.length} total rows</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/ui/DataTable/TableHeader.tsx
Normal file
83
apps/web/src/components/ui/DataTable/TableHeader.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
|
||||
interface TableHeaderProps<T> {
|
||||
table: Table<T>;
|
||||
height: number;
|
||||
totalWidth: number;
|
||||
}
|
||||
|
||||
export function TableHeader<T>({ table, height, totalWidth }: TableHeaderProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className="bg-surface border-b border-border overflow-hidden"
|
||||
style={{ height, minWidth: totalWidth }}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{table.getVisibleFlatColumns().map(column => (
|
||||
<div
|
||||
key={column.id}
|
||||
className={cn(
|
||||
'flex items-center px-2 text-xs font-medium text-text-secondary uppercase tracking-wider',
|
||||
'border-r border-border bg-surface hover:bg-surface-secondary',
|
||||
column.getCanSort() && 'cursor-pointer select-none'
|
||||
)}
|
||||
style={{ width: column.getSize() }}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="truncate">
|
||||
{flexRender(column.columnDef.header, column.getContext())}
|
||||
</span>
|
||||
|
||||
{column.getCanSort() && (
|
||||
<div className="flex flex-col ml-1">
|
||||
{column.getIsSorted() === 'asc' ? (
|
||||
<ChevronUpIcon className="h-3 w-3 text-primary-400" />
|
||||
) : column.getIsSorted() === 'desc' ? (
|
||||
<ChevronDownIcon className="h-3 w-3 text-primary-400" />
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUpIcon className="h-2 w-2 text-text-muted" />
|
||||
<ChevronDownIcon className="h-2 w-2 text-text-muted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{column.getCanGroup() && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
column.toggleGrouping();
|
||||
}}
|
||||
className={cn(
|
||||
'ml-1 px-1 py-0.5 text-xs rounded',
|
||||
column.getIsGrouped()
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-surface-tertiary text-text-muted hover:bg-surface-secondary'
|
||||
)}
|
||||
>
|
||||
G
|
||||
</button>
|
||||
)}
|
||||
|
||||
{column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={column.getResizeHandler()}
|
||||
onTouchStart={column.getResizeHandler()}
|
||||
className={cn(
|
||||
'absolute right-0 top-0 h-full w-1 cursor-col-resize',
|
||||
'hover:bg-primary-500 opacity-0 hover:opacity-100',
|
||||
column.getIsResizing() && 'bg-primary-500 opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/components/ui/DataTable/index.ts
Normal file
1
apps/web/src/components/ui/DataTable/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DataTable } from './DataTable';
|
||||
66
apps/web/src/components/ui/DataTable/types.ts
Normal file
66
apps/web/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/web/src/components/ui/StatCard.tsx
Normal file
35
apps/web/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>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/components/ui/index.ts
Normal file
3
apps/web/src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Card, CardHeader, CardContent } from './Card';
|
||||
export { StatCard } from './StatCard';
|
||||
export { DataTable } from './DataTable';
|
||||
16
apps/web/src/features/dashboard/DashboardPage.tsx
Normal file
16
apps/web/src/features/dashboard/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { DashboardStats, DashboardActivity, PortfolioTable } from './components';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<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 />
|
||||
<PortfolioTable />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
652
apps/web/src/features/dashboard/components/PortfolioTable.tsx
Normal file
652
apps/web/src/features/dashboard/components/PortfolioTable.tsx
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
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 (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-bold text-text-primary mb-2">Portfolio Holdings</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Performance test: 100,000 rows × {columns.length} columns ={' '}
|
||||
{(100000 * columns.length).toLocaleString()} cells
|
||||
</p>
|
||||
<div className="h-96 w-full border border-border rounded-lg overflow-hidden">
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height="100%"
|
||||
onRowClick={row => console.log('Clicked:', row.symbol)}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/features/dashboard/components/index.ts
Normal file
3
apps/web/src/features/dashboard/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DashboardStats } from './DashboardStats';
|
||||
export { DashboardActivity } from './DashboardActivity';
|
||||
export { PortfolioTable } from './PortfolioTable';
|
||||
1
apps/web/src/features/dashboard/index.ts
Normal file
1
apps/web/src/features/dashboard/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DashboardPage } from './DashboardPage';
|
||||
120
apps/web/src/index.css
Normal file
120
apps/web/src/index.css
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
@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 {
|
||||
/* Custom scrollbar for dark theme */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(42 42 45) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(42 42 45);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(51 51 54);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
1
apps/web/src/lib/constants/index.ts
Normal file
1
apps/web/src/lib/constants/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './navigation';
|
||||
40
apps/web/src/lib/constants/navigation.ts
Normal file
40
apps/web/src/lib/constants/navigation.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
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: 'Reports',
|
||||
href: '/reports',
|
||||
icon: DocumentTextIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: CogIcon,
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
6
apps/web/src/lib/utils/cn.ts
Normal file
6
apps/web/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));
|
||||
}
|
||||
47
apps/web/src/lib/utils/index.ts
Normal file
47
apps/web/src/lib/utils/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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) + '...';
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
93
apps/web/tailwind.config.js
Normal file
93
apps/web/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/web/tsconfig.json
Normal file
26
apps/web/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/web/tsconfig.node.json
Normal file
10
apps/web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
20
apps/web/turbo.json
Normal file
20
apps/web/turbo.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
},
|
||||
"type-check": {
|
||||
"dependsOn": ["^type-check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/web/vite.config.ts
Normal file
17
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue