diff --git a/CHANGELOG.md b/CHANGELOG.md index 27588dd17..204a476b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added an account page as a preparation for the multi accounts support + ## 0.96.0 - 30.04.2021 ### Added diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts new file mode 100644 index 000000000..581e52c0b --- /dev/null +++ b/apps/api/src/app/account/account.controller.ts @@ -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 { + 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 { + 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 { + return this.accountService.account({ + id_userId: { + id, + userId: this.request.user.id + } + }); + } + + @Post() + @UseGuards(AuthGuard('jwt')) + public async createAccount( + @Body() data: CreateAccountDto + ): Promise { + 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 + ); + } + } +} diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts new file mode 100644 index 000000000..3ac6c0ad0 --- /dev/null +++ b/apps/api/src/app/account/account.module.ts @@ -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 {} diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts new file mode 100644 index 000000000..92d39695c --- /dev/null +++ b/apps/api/src/app/account/account.service.ts @@ -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 { + 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 { + 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 { + return this.prisma.account.create({ + data + }); + } + + public async deleteAccount( + where: Prisma.AccountWhereUniqueInput, + aUserId: string + ): Promise { + this.redisCacheService.remove(`${aUserId}.portfolio`); + + return this.prisma.account.delete({ + where + }); + } + + public async updateAccount( + params: { + where: Prisma.AccountWhereUniqueInput; + data: Prisma.AccountUpdateInput; + }, + aUserId: string + ): Promise { + const { data, where } = params; + return this.prisma.account.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts new file mode 100644 index 000000000..9134a3f1d --- /dev/null +++ b/apps/api/src/app/account/create-account.dto.ts @@ -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; +} diff --git a/apps/api/src/app/account/interfaces/order-with-platform.type.ts b/apps/api/src/app/account/interfaces/order-with-platform.type.ts new file mode 100644 index 000000000..10cc70e1a --- /dev/null +++ b/apps/api/src/app/account/interfaces/order-with-platform.type.ts @@ -0,0 +1,3 @@ +import { Order, Platform } from '@prisma/client'; + +export type OrderWithPlatform = Order & { Platform?: Platform }; diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts new file mode 100644 index 000000000..9c31ed5df --- /dev/null +++ b/apps/api/src/app/account/update-account.dto.ts @@ -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; +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 8f3f41564..20f0b3103 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -16,6 +16,7 @@ import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yah import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { PrismaService } from '../services/prisma.service'; import { AccessModule } from './access/access.module'; +import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; import { AuthModule } from './auth/auth.module'; @@ -32,6 +33,7 @@ import { UserModule } from './user/user.module'; imports: [ AdminModule, AccessModule, + AccountModule, AuthModule, CacheModule, ConfigModule.forRoot(), diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 63bd91943..0c459b2a7 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -71,7 +71,11 @@ export class OrderController { let orders = await this.orderService.orders({ include: { - Platform: true + Account: { + include: { + Platform: true + } + } }, orderBy: { date: 'desc' }, where: { userId: impersonationUserId || this.request.user.id } diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts index be3c961b2..c1856b29c 100644 --- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts @@ -1,6 +1,6 @@ -import { DataSource } from '.prisma/client'; import { getYesterday } from '@ghostfolio/helper'; import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; import * as bent from 'bent'; import * as cheerio from 'cheerio'; import { format } from 'date-fns'; diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-config.interface.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-config.interface.ts index 989594169..c6900ecf3 100644 --- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-config.interface.ts +++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-config.interface.ts @@ -1,4 +1,4 @@ -import { Currency } from '.prisma/client'; +import { Currency } from '@prisma/client'; export interface ScraperConfig { currency: Currency; diff --git a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts index f45e85fc7..addcd685e 100644 --- a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts @@ -1,6 +1,6 @@ -import { DataSource } from '.prisma/client'; import { getToday, getYesterday } from '@ghostfolio/helper'; import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; import * as bent from 'bent'; import { format, subMonths, subWeeks, subYears } from 'date-fns'; diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 90f9da459..8d53a6bfe 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -1,6 +1,6 @@ -import { DataSource } from '.prisma/client'; import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper'; import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; import { format } from 'date-fns'; import * as yahooFinance from 'yahoo-finance'; diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 145744e91..920565529 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -9,11 +9,6 @@ const routes: Routes = [ loadChildren: () => import('./pages/about/about-page.module').then((m) => m.AboutPageModule) }, - { - path: 'admin', - loadChildren: () => - import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) - }, { path: 'account', loadChildren: () => @@ -21,6 +16,18 @@ const routes: Routes = [ (m) => m.AccountPageModule ) }, + { + path: 'accounts', + loadChildren: () => + import('./pages/accounts/accounts-page.module').then( + (m) => m.AccountsPageModule + ) + }, + { + path: 'admin', + loadChildren: () => + import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) + }, { path: 'auth', loadChildren: () => diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 71fe416f5..22e7fe6f8 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -74,7 +74,7 @@ export class AppComponent implements OnDestroy, OnInit { this.canCreateAccount = hasPermission( this.user.permissions, - permissions.createAccount + permissions.createUserAccount ); this.cd.markForCheck(); diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html new file mode 100644 index 000000000..5796d82ac --- /dev/null +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + +
Name + {{ element.name }} + + Type + +
+ {{ element.accountType }} +
+
Platform +
+ + {{ element.Platform?.name }} +
+
+ + + + + +
+ + diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.scss b/apps/client/src/app/components/accounts-table/accounts-table.component.scss new file mode 100644 index 000000000..8c714f828 --- /dev/null +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.scss @@ -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); + } + } + } +} diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.ts b/apps/client/src/app/components/accounts-table/accounts-table.component.ts new file mode 100644 index 000000000..a35751172 --- /dev/null +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.ts @@ -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(); + @Output() accountToUpdate = new EventEmitter(); + + @ViewChild(MatSort) sort: MatSort; + + public dataSource: MatTableDataSource = new MatTableDataSource(); + public displayedColumns = []; + public isLoading = true; + public routeQueryParams: Subscription; + + private unsubscribeSubject = new Subject(); + + 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(); + } +} diff --git a/apps/client/src/app/components/accounts-table/accounts-table.module.ts b/apps/client/src/app/components/accounts-table/accounts-table.module.ts new file mode 100644 index 000000000..3855c5175 --- /dev/null +++ b/apps/client/src/app/components/accounts-table/accounts-table.module.ts @@ -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 {} diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 130dc9754..d48791d07 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -36,6 +36,14 @@ [color]="currentRoute === 'transactions' ? 'primary' : null" >Transactions + Accounts Transactions + Accounts - - - Platform - - -
+ + Account + +
+ {{ element.Account?.name }}
+ (); + + /** + * @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 }); + }); + } +} diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html new file mode 100644 index 000000000..f4cc36223 --- /dev/null +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -0,0 +1,31 @@ +
diff --git a/apps/client/src/app/pages/accounts/accounts-page.module.ts b/apps/client/src/app/pages/accounts/accounts-page.module.ts new file mode 100644 index 000000000..ec0212ed5 --- /dev/null +++ b/apps/client/src/app/pages/accounts/accounts-page.module.ts @@ -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 {} diff --git a/apps/client/src/app/pages/accounts/accounts-page.scss b/apps/client/src/app/pages/accounts/accounts-page.scss new file mode 100644 index 000000000..31c10a3d7 --- /dev/null +++ b/apps/client/src/app/pages/accounts/accounts-page.scss @@ -0,0 +1,8 @@ +:host { + .fab-container { + position: fixed; + right: 2rem; + bottom: 2rem; + z-index: 999; + } +} diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts new file mode 100644 index 000000000..24a7df79b --- /dev/null +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts @@ -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; + public isLoading = false; + public platforms: { id: string; name: string }[]; + public searchSymbolCtrl = new FormControl( + this.data.transaction.symbol, + Validators.required + ); + + private unsubscribeSubject = new Subject(); + + public constructor( + private cd: ChangeDetectorRef, + private dataService: DataService, + public dialogRef: MatDialogRef, + @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(); + } +} diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html new file mode 100644 index 000000000..bfadfd8a0 --- /dev/null +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -0,0 +1,163 @@ +
+

Update account

+

Add account

+
+
+ + Symbol or ISIN + + + + + {{ lookupItem.symbol | gfSymbol }}{{ lookupItem.name }} + + + + + +
+
+ + Type + + BUY + SELL + + +
+
+ + Currency + + {{ currency }} + + +
+
+ + Data Source + + +
+
+ + Date + + + + +
+
+ + Fee + + +
+
+ + Quantity + + +
+
+ + Unit Price + + +
+
+ + Account + + {{ account.name }} + + +
+
+ + Platform + + + {{ platform.name }} + + +
+
+
+ + +
+
diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts new file mode 100644 index 000000000..f0990f4a7 --- /dev/null +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts @@ -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 {} diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.scss b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.scss new file mode 100644 index 000000000..0ce226588 --- /dev/null +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.scss @@ -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); + } + } + } +} diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..51cb3bdda --- /dev/null +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts @@ -0,0 +1,9 @@ +import { Account } from '@prisma/client'; + +import { Order } from '../../interfaces/order.interface'; + +export interface CreateOrUpdateAccountDialogParams { + accountId: string; + accounts: Account[]; + transaction: Order; +} diff --git a/apps/client/src/app/pages/accounts/interfaces/order.interface.ts b/apps/client/src/app/pages/accounts/interfaces/order.interface.ts new file mode 100644 index 000000000..12f6626a2 --- /dev/null +++ b/apps/client/src/app/pages/accounts/interfaces/order.interface.ts @@ -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; +} diff --git a/apps/client/src/app/pages/transactions/interfaces/order.interface.ts b/apps/client/src/app/pages/transactions/interfaces/order.interface.ts index fcdf273b1..12f6626a2 100644 --- a/apps/client/src/app/pages/transactions/interfaces/order.interface.ts +++ b/apps/client/src/app/pages/transactions/interfaces/order.interface.ts @@ -1,4 +1,4 @@ -import { Currency, DataSource } from '.prisma/client'; +import { Currency, DataSource } from '@prisma/client'; export interface Order { accountId: string; diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index b29807df7..fec978c49 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface'; +import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface'; import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; @@ -28,10 +29,18 @@ export class DataService { public constructor(private http: HttpClient) {} + public fetchAccounts() { + return this.http.get('/api/account'); + } + public fetchAdminData() { return this.http.get('/api/admin'); } + public deleteAccount(aId: string) { + return this.http.delete(`/api/account/${aId}`); + } + public deleteOrder(aId: string) { return this.http.delete(`/api/order/${aId}`); } @@ -108,6 +117,10 @@ export class DataService { return this.http.get(`/api/auth/anonymous/${accessToken}`); } + public postAccount(aAccount: UpdateAccountDto) { + return this.http.post(`/api/account`, aAccount); + } + public postOrder(aOrder: UpdateOrderDto) { return this.http.post(`/api/order`, aOrder); } @@ -116,6 +129,10 @@ export class DataService { return this.http.post(`/api/user`, {}); } + public putAccount(aAccount: UpdateAccountDto) { + return this.http.put(`/api/account/${aAccount.id}`, aAccount); + } + public putOrder(aOrder: UpdateOrderDto) { return this.http.put(`/api/order/${aOrder.id}`, aOrder); } diff --git a/libs/helper/src/lib/permissions.ts b/libs/helper/src/lib/permissions.ts index 99093aaf1..5a2eb313f 100644 --- a/libs/helper/src/lib/permissions.ts +++ b/libs/helper/src/lib/permissions.ts @@ -9,10 +9,13 @@ export const permissions = { accessFearAndGreedIndex: 'accessFearAndGreedIndex', createAccount: 'createAccount', createOrder: 'createOrder', + createUserAccount: 'createUserAccount', + deleteAccount: 'deleteAcccount', deleteOrder: 'deleteOrder', enableSocialLogin: 'enableSocialLogin', enableSubscription: 'enableSubscription', readForeignPortfolio: 'readForeignPortfolio', + updateAccount: 'updateAccount', updateOrder: 'updateOrder', updateUserSettings: 'updateUserSettings' }; @@ -37,7 +40,7 @@ export function getPermissions(aRole: Role): string[] { ]; case 'DEMO': - return [permissions.createAccount]; + return [permissions.createUserAccount]; case 'USER': return [