Feature/migration to accounts (#58)
* Migrate transaction table * Add accounts page * Add account page logicpull/59/head
parent
e3a1d2b9cf
commit
c70eb7793e
@ -0,0 +1,213 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './create-account.dto';
|
||||
import { UpdateAccountDto } from './update-account.dto';
|
||||
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAccount
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountService.deleteAccount(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<AccountModel[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accounts = await this.accountService.accounts({
|
||||
include: { Platform: true },
|
||||
orderBy: { name: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
accounts = nullifyValuesInObjects(accounts, [
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice'
|
||||
]);
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
|
||||
return this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccount(
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createAccount
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateAccount
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalAccount = await this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.updateAccount(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
// platformId is null, remove it
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.updateAccount(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: originalAccount.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [AccountController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class AccountModule {}
|
@ -0,0 +1,75 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Prisma } from '@prisma/client';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prisma.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accounts(params: {
|
||||
include?: Prisma.AccountInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByInput;
|
||||
}): Promise<Account[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount(
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prisma.account.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAccount(
|
||||
params: {
|
||||
where: Prisma.AccountWhereUniqueInput;
|
||||
data: Prisma.AccountUpdateInput;
|
||||
},
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
return this.prisma.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { Order, Platform } from '@prisma/client';
|
||||
|
||||
export type OrderWithPlatform = Order & { Platform?: Platform };
|
@ -0,0 +1,38 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
<table
|
||||
class="w-100"
|
||||
matSort
|
||||
matSortActive="account"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Name</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.name }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-center"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell text-center"
|
||||
>
|
||||
<div class="d-inline-flex justify-content-center px-2 py-1 type-badge">
|
||||
<span>{{ element.accountType }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="platform">
|
||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Platform</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]=""
|
||||
[url]="element.Platform?.url"
|
||||
></gf-symbol-icon>
|
||||
<span>{{ element.Platform?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button i18n mat-menu-item (click)="onUpdateAccount(element)">
|
||||
Edit
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onDeleteAccount(element.id)">
|
||||
Delete
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||
</table>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="px-4 py-3"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
@ -0,0 +1,59 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
::ng-deep {
|
||||
.mat-form-field-infix {
|
||||
border-top: 0 solid transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--dark-primary-text),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background-color: rgba(var(--dark-primary-text), 0.05);
|
||||
border-radius: 1rem;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-form-field {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--light-primary-text),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background-color: rgba(var(--light-primary-text), 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-accounts-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './accounts-table.component.html',
|
||||
styleUrls: ['./accounts-table.component.scss']
|
||||
})
|
||||
export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() accounts: OrderModel[];
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() locale: string;
|
||||
@Input() showActions: boolean;
|
||||
|
||||
@Output() accountDeleted = new EventEmitter<string>();
|
||||
@Output() accountToUpdate = new EventEmitter<OrderModel>();
|
||||
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public dataSource: MatTableDataSource<OrderModel> = new MatTableDataSource();
|
||||
public displayedColumns = [];
|
||||
public isLoading = true;
|
||||
public routeQueryParams: Subscription;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = ['account', 'type', 'platform'];
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
if (this.showActions) {
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
|
||||
if (this.accounts) {
|
||||
this.dataSource = new MatTableDataSource(this.accounts);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteAccount(aId: string) {
|
||||
const confirmation = confirm('Do you really want to delete this account?');
|
||||
|
||||
if (confirmation) {
|
||||
this.accountDeleted.emit(aId);
|
||||
}
|
||||
}
|
||||
|
||||
public onUpdateAccount(aAccount: OrderModel) {
|
||||
this.accountToUpdate.emit(aAccount);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { GfValueModule } from '../value/value.module';
|
||||
import { AccountsTableComponent } from './accounts-table.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AccountsTableComponent],
|
||||
exports: [AccountsTableComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfSymbolIconModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfAccountsTableModule {}
|
@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { AccountsPageComponent } from './accounts-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AccountsPageRoutingModule {}
|
@ -0,0 +1,215 @@
|
||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-accounts-page',
|
||||
templateUrl: './accounts-page.html',
|
||||
styleUrls: ['./accounts-page.scss']
|
||||
})
|
||||
export class AccountsPageComponent implements OnInit {
|
||||
public accounts: OrderModel[];
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateAccount: boolean;
|
||||
public hasPermissionToDeleteAccount: boolean;
|
||||
public routeQueryParams: Subscription;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog']) {
|
||||
this.openCreateAccountDialog();
|
||||
} else if (params['editDialog']) {
|
||||
if (this.accounts) {
|
||||
const account = this.accounts.find((account) => {
|
||||
return account.id === params['transactionId'];
|
||||
});
|
||||
|
||||
this.openUpdateAccountDialog(account);
|
||||
} else {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
});
|
||||
|
||||
this.tokenStorageService
|
||||
.onChangeHasToken()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.dataService.fetchUser().subscribe((user) => {
|
||||
this.user = user;
|
||||
this.hasPermissionToCreateAccount = hasPermission(
|
||||
user.permissions,
|
||||
permissions.createAccount
|
||||
);
|
||||
this.hasPermissionToDeleteAccount = hasPermission(
|
||||
user.permissions,
|
||||
permissions.deleteAccount
|
||||
);
|
||||
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
});
|
||||
|
||||
this.fetchAccounts();
|
||||
}
|
||||
|
||||
public fetchAccounts() {
|
||||
this.dataService.fetchAccounts().subscribe((response) => {
|
||||
this.accounts = response;
|
||||
|
||||
if (this.accounts?.length <= 0) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteAccount(aId: string) {
|
||||
this.dataService.deleteAccount(aId).subscribe({
|
||||
next: () => {
|
||||
this.fetchAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateAccount(aAccount: OrderModel) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { editDialog: true, transactionId: aAccount.id }
|
||||
});
|
||||
}
|
||||
|
||||
public openUpdateAccountDialog({
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
platformId,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
}: OrderModel): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
|
||||
data: {
|
||||
accounts: this.user.accounts,
|
||||
transaction: {
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
platformId,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
}
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((data: any) => {
|
||||
const transaction: UpdateOrderDto = data?.transaction;
|
||||
|
||||
if (transaction) {
|
||||
this.dataService.putAccount(transaction).subscribe({
|
||||
next: () => {
|
||||
this.fetchAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openCreateAccountDialog(): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
|
||||
data: {
|
||||
accounts: this.user?.accounts,
|
||||
transaction: {
|
||||
accountId: this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id,
|
||||
currency: null,
|
||||
date: new Date(),
|
||||
fee: 0,
|
||||
platformId: null,
|
||||
quantity: null,
|
||||
symbol: null,
|
||||
type: 'BUY',
|
||||
unitPrice: null
|
||||
}
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((data: any) => {
|
||||
const transaction: UpdateOrderDto = data?.transaction;
|
||||
|
||||
if (transaction) {
|
||||
this.dataService.postAccount(transaction).subscribe({
|
||||
next: () => {
|
||||
this.fetchAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
|
||||
<gf-accounts-table
|
||||
[accounts]="accounts"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount"
|
||||
(accountDeleted)="onDeleteAccount($event)"
|
||||
(accountToUpdate)="onUpdateAccount($event)"
|
||||
></gf-accounts-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!hasImpersonationId && hasPermissionToCreateAccount"
|
||||
class="fab-container"
|
||||
>
|
||||
<a
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ createDialog: true }"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
|
||||
|
||||
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
|
||||
import { AccountsPageComponent } from './accounts-page.component';
|
||||
import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AccountsPageComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
AccountsPageRoutingModule,
|
||||
CommonModule,
|
||||
CreateOrUpdateAccountDialogModule,
|
||||
GfAccountsTableModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class AccountsPageModule {}
|
@ -0,0 +1,8 @@
|
||||
:host {
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
bottom: 2rem;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject
|
||||
} from '@angular/core';
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { DataService } from '../../../services/data.service';
|
||||
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
host: { class: 'h-100' },
|
||||
selector: 'create-or-update-account-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./create-or-update-account-dialog.scss'],
|
||||
templateUrl: 'create-or-update-account-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateAccountDialog {
|
||||
public currencies: Currency[] = [];
|
||||
public filteredLookupItems: Observable<LookupItem[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public searchSymbolCtrl = new FormControl(
|
||||
this.data.transaction.symbol,
|
||||
Validators.required
|
||||
);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
|
||||
this.currencies = currencies;
|
||||
this.platforms = platforms;
|
||||
});
|
||||
|
||||
this.filteredLookupItems = this.searchSymbolCtrl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((aQuery: string) => {
|
||||
if (aQuery) {
|
||||
return this.dataService.fetchSymbols(aQuery);
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.isLoading = true;
|
||||
this.data.transaction.symbol = event.option.value;
|
||||
|
||||
this.dataService
|
||||
.fetchSymbolItem(this.data.transaction.symbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.data.transaction.currency = currency;
|
||||
this.data.transaction.dataSource = dataSource;
|
||||
this.data.transaction.unitPrice = marketPrice;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateSymbolByTyping(value: string) {
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.unitPrice = null;
|
||||
|
||||
this.data.transaction.symbol = value;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
<form #addAccountForm="ngForm" class="d-flex flex-column h-100">
|
||||
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update account</h1>
|
||||
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add account</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Symbol or ISIN</mat-label>
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
matInput
|
||||
required
|
||||
[formControl]="searchSymbolCtrl"
|
||||
[matAutocomplete]="auto"
|
||||
(change)="onUpdateSymbolByTyping($event.target.value)"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#auto="matAutocomplete"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
<ng-container>
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItems | async"
|
||||
class="autocomplete"
|
||||
[value]="lookupItem.symbol"
|
||||
>
|
||||
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
|
||||
><span><b>{{ lookupItem.name }}</b></span>
|
||||
</mat-option>
|
||||
</ng-container>
|
||||
</mat-autocomplete>
|
||||
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select name="type" required [(value)]="data.transaction.type">
|
||||
<mat-option value="BUY" i18n> BUY </mat-option>
|
||||
<mat-option value="SELL" i18n> SELL </mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<mat-select
|
||||
class="no-arrow"
|
||||
disabled
|
||||
name="currency"
|
||||
required
|
||||
[(value)]="data.transaction.currency"
|
||||
>
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency"
|
||||
>{{ currency }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Data Source</mat-label>
|
||||
<input
|
||||
disabled
|
||||
matInput
|
||||
name="dataSource"
|
||||
required
|
||||
[(ngModel)]="data.transaction.dataSource"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
<input
|
||||
disabled
|
||||
matInput
|
||||
name="date"
|
||||
required
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.transaction.date"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
|
||||
<mat-datepicker #date disabled="false"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="fee"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.fee"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Quantity</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="quantity"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.quantity"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Unit Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="unitPrice"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.unitPrice"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Account</mat-label>
|
||||
<mat-select
|
||||
disabled
|
||||
name="accountId"
|
||||
required
|
||||
[(value)]="data.transaction.accountId"
|
||||
>
|
||||
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
|
||||
>{{ account.name }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Platform</mat-label>
|
||||
<mat-select name="platformId" [(value)]="data.transaction.platformId">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
|
||||
>{{ platform.name }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!(addAccountForm.form.valid && data.transaction.symbol)"
|
||||
[mat-dialog-close]="data"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,35 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CreateOrUpdateAccountDialog],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfSymbolModule,
|
||||
FormsModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class CreateOrUpdateAccountDialogModule {}
|
@ -0,0 +1,43 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.autocomplete {
|
||||
font-size: 90%;
|
||||
height: 2.5rem;
|
||||
|
||||
.symbol {
|
||||
display: inline-block;
|
||||
min-width: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-select {
|
||||
&.no-arrow {
|
||||
::ng-deep {
|
||||
.mat-select-arrow {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-datepicker-input {
|
||||
&.mat-input-element:disabled {
|
||||
color: var(--dark-primary-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-dialog-content {
|
||||
.mat-datepicker-input {
|
||||
&.mat-input-element:disabled {
|
||||
color: var(--light-primary-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
import { Order } from '../../interfaces/order.interface';
|
||||
|
||||
export interface CreateOrUpdateAccountDialogParams {
|
||||
accountId: string;
|
||||
accounts: Account[];
|
||||
transaction: Order;
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
export interface Order {
|
||||
accountId: string;
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
fee: number;
|
||||
id: string;
|
||||
quantity: number;
|
||||
platformId: string;
|
||||
symbol: string;
|
||||
type: string;
|
||||
unitPrice: number;
|
||||
}
|
Loading…
Reference in new issue