mirror of https://github.com/ghostfolio/ghostfolio
Thomas
4 years ago
committed by
GitHub
37 changed files with 1400 additions and 30 deletions
@ -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