From 4c10e2cb732c18d53654cb64c43231527950fd56 Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Sun, 26 Jun 2022 18:23:05 +0200 Subject: [PATCH] Add account detail dialog --- apps/api/src/app/order/order.controller.ts | 33 +++++- .../account-detail-dialog.component.scss | 3 + .../account-detail-dialog.component.ts | 112 ++++++++++++++++++ .../account-detail-dialog.html | 65 ++++++++++ .../account-detail-dialog.module.ts | 29 +++++ .../interfaces/interfaces.ts | 5 + .../accounts-table.component.html | 9 +- .../accounts-table.component.ts | 10 +- .../pages/accounts/accounts-page.component.ts | 32 ++++- .../src/app/pages/accounts/accounts-page.html | 1 + .../pages/accounts/accounts-page.module.ts | 4 +- .../transactions-page.component.ts | 14 +-- apps/client/src/app/services/data.service.ts | 83 +++++++++++-- .../src/lib/types/account-with-value.type.ts | 3 +- 14 files changed, 376 insertions(+), 27 deletions(-) create mode 100644 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss create mode 100644 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts create mode 100644 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html create mode 100644 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts create mode 100644 apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index e61c57ef7..1d62e79a3 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; +import { Filter } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -17,6 +18,7 @@ import { Param, Post, Put, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -66,8 +68,36 @@ export class OrderController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllOrders( - @Headers('impersonation-id') impersonationId + @Headers('impersonation-id') impersonationId, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('tags') filterByTags?: string ): Promise { + const accountIds = filterByAccounts?.split(',') ?? []; + const assetClasses = filterByAssetClasses?.split(',') ?? []; + const tagIds = filterByTags?.split(',') ?? []; + + const filters: Filter[] = [ + ...accountIds.map((accountId) => { + return { + id: accountId, + type: 'ACCOUNT' + }; + }), + ...assetClasses.map((assetClass) => { + return { + id: assetClass, + type: 'ASSET_CLASS' + }; + }), + ...tagIds.map((tagId) => { + return { + id: tagId, + type: 'TAG' + }; + }) + ]; + const impersonationUserId = await this.impersonationService.validateImpersonationId( impersonationId, @@ -76,6 +106,7 @@ export class OrderController { const userCurrency = this.request.user.Settings.currency; let activities = await this.orderService.getOrders({ + filters, userCurrency, includeDrafts: true, userId: impersonationUserId || this.request.user.id diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts new file mode 100644 index 000000000..ca2f229e7 --- /dev/null +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -0,0 +1,112 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { downloadAsFile } from '@ghostfolio/common/helper'; +import { User } from '@ghostfolio/common/interfaces'; +import { OrderWithAccount } from '@ghostfolio/common/types'; +import { AccountType } from '@prisma/client'; +import { format, parseISO } from 'date-fns'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { AccountDetailDialogParams } from './interfaces/interfaces'; + +@Component({ + host: { class: 'd-flex flex-column h-100' }, + selector: 'gf-account-detail-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: 'account-detail-dialog.html', + styleUrls: ['./account-detail-dialog.component.scss'] +}) +export class AccountDetailDialog implements OnDestroy, OnInit { + public accountType: AccountType; + public name: string; + public orders: OrderWithAccount[]; + public platformName: string; + public user: User; + public valueInBaseCurrency: number; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, + private dataService: DataService, + public dialogRef: MatDialogRef, + private userService: UserService + ) { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit(): void { + this.dataService + .fetchAccount(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => { + this.accountType = accountType; + this.name = name; + this.platformName = Platform?.name; + this.valueInBaseCurrency = valueInBaseCurrency; + + this.changeDetectorRef.markForCheck(); + }); + + this.dataService + .fetchActivities({ + filters: [{ id: this.data.accountId, type: 'ACCOUNT' }] + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ activities }) => { + this.orders = activities; + + this.changeDetectorRef.markForCheck(); + }); + } + + public onClose(): void { + this.dialogRef.close(); + } + + public onExport() { + this.dataService + .fetchExport( + this.orders.map((order) => { + return order.id; + }) + ) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + downloadAsFile({ + content: data, + fileName: `ghostfolio-export-${this.name + .replace(/\s+/g, '-') + .toLowerCase()}-${format( + parseISO(data.meta.date), + 'yyyyMMddHHmm' + )}.json`, + format: 'json' + }); + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html new file mode 100644 index 000000000..150f74e74 --- /dev/null +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -0,0 +1,65 @@ + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
Activities
+ +
+
+
+
+ + diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts new file mode 100644 index 000000000..8c5b7abcd --- /dev/null +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; +import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; +import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; +import { GfValueModule } from '@ghostfolio/ui/value'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { AccountDetailDialog } from './account-detail-dialog.component'; + +@NgModule({ + declarations: [AccountDetailDialog], + exports: [], + imports: [ + CommonModule, + GfActivitiesTableModule, + GfDialogFooterModule, + GfDialogHeaderModule, + GfValueModule, + MatButtonModule, + MatDialogModule, + NgxSkeletonLoaderModule + ], + providers: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAccountDetailDialogModule {} diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..016fc3b7d --- /dev/null +++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface AccountDetailDialogParams { + accountId: string; + deviceType: string; + hasImpersonationId: boolean; +} 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 index 08c6c3de9..434ec9410 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.html +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -65,7 +65,7 @@ # - Transactions + Activities {{ @@ -212,7 +212,12 @@ - + (); - public constructor() {} + public constructor(private router: Router) {} public ngOnInit() {} @@ -75,6 +77,12 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { } } + public onOpenAccountDetailDialog(accountId: string) { + this.router.navigate([], { + queryParams: { accountId, accountDetailDialog: true } + }); + } + public onUpdateAccount(aAccount: AccountModel) { this.accountToUpdate.emit(aAccount); } diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 826e2e622..65952b00a 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -3,6 +3,8 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; +import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; +import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; @@ -27,6 +29,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; public hasPermissionToCreateAccount: boolean; public hasPermissionToDeleteAccount: boolean; + public hasPermissionToShowValues: boolean; public routeQueryParams: Subscription; public totalBalanceInBaseCurrency = 0; public totalValueInBaseCurrency = 0; @@ -48,7 +51,12 @@ export class AccountsPageComponent implements OnDestroy, OnInit { this.route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { - if (params['createDialog'] && this.hasPermissionToCreateAccount) { + if (params['accountId'] && params['accountDetailDialog']) { + this.openAccountDetailDialog(params['accountId']); + } else if ( + params['createDialog'] && + this.hasPermissionToCreateAccount + ) { this.openCreateAccountDialog(); } else if (params['editDialog']) { if (this.accounts) { @@ -72,6 +80,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((aId) => { this.hasImpersonationId = !!aId; + + this.hasPermissionToShowValues = !this.hasImpersonationId; }); this.userService.stateChanged @@ -197,6 +207,26 @@ export class AccountsPageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + private openAccountDetailDialog(aAccountId) { + const dialogRef = this.dialog.open(AccountDetailDialog, { + autoFocus: false, + data: { + accountId: aAccountId, + deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } + private openCreateAccountDialog(): void { const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, { data: { diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html index 228ccdd78..c5ddd9f7f 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.html +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -7,6 +7,7 @@ [accounts]="accounts" [baseCurrency]="user?.settings?.baseCurrency" [deviceType]="deviceType" + [hasPermissionToShowValues]="hasPermissionToShowValues" [locale]="user?.settings?.locale" [showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" diff --git a/apps/client/src/app/pages/accounts/accounts-page.module.ts b/apps/client/src/app/pages/accounts/accounts-page.module.ts index b9de21ff8..9edb43ba7 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.module.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.module.ts @@ -2,6 +2,7 @@ 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 { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module'; import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module'; import { AccountsPageRoutingModule } from './accounts-page-routing.module'; @@ -10,16 +11,15 @@ import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account- @NgModule({ declarations: [AccountsPageComponent], - exports: [], imports: [ AccountsPageRoutingModule, CommonModule, + GfAccountDetailDialogModule, GfAccountsTableModule, GfCreateOrUpdateAccountDialogModule, MatButtonModule, RouterModule ], - providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AccountsPageModule {} diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index 66ffee9df..80c0701b5 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -111,12 +111,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { } }); - this.fetchOrders(); + this.fetchActivities(); } - public fetchOrders() { + public fetchActivities() { this.dataService - .fetchOrders() + .fetchActivities({}) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ activities }) => { this.activities = activities; @@ -139,7 +139,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe({ next: () => { - this.fetchOrders(); + this.fetchActivities(); } }); } @@ -298,7 +298,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe({ next: () => { - this.fetchOrders(); + this.fetchActivities(); } }); } @@ -332,7 +332,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { } private handleImportSuccess() { - this.fetchOrders(); + this.fetchActivities(); this.snackBar.open('✅ Import has been completed', undefined, { duration: 3000 @@ -376,7 +376,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { if (transaction) { this.dataService.postOrder(transaction).subscribe({ next: () => { - this.fetchOrders(); + this.fetchActivities(); } }); } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index f6b92ac2a..d3d546d04 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -59,10 +59,81 @@ export class DataService { }); } + public fetchAccount(aAccountId: string) { + return this.http.get('/api/v1/account').pipe( + map((response) => { + return response.accounts.find((account) => { + return account.id === aAccountId; + }); + }) + ); + } + public fetchAccounts() { return this.http.get('/api/v1/account'); } + public fetchActivities({ + filters + }: { + filters?: Filter[]; + }): Observable { + let params = new HttpParams(); + + if (filters?.length > 0) { + const { + ACCOUNT: filtersByAccount, + ASSET_CLASS: filtersByAssetClass, + TAG: filtersByTag + } = groupBy(filters, (filter) => { + return filter.type; + }); + + if (filtersByAccount) { + params = params.append( + 'accounts', + filtersByAccount + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByAssetClass) { + params = params.append( + 'assetClasses', + filtersByAssetClass + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByTag) { + params = params.append( + 'tags', + filtersByTag + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + } + + return this.http.get('/api/v1/order', { params }).pipe( + map(({ activities }) => { + for (const activity of activities) { + activity.createdAt = parseISO(activity.createdAt); + activity.date = parseISO(activity.date); + } + return { activities }; + }) + ); + } + public fetchAdminData() { return this.http.get('/api/v1/admin'); } @@ -179,18 +250,6 @@ export class DataService { ); } - public fetchOrders(): Observable { - return this.http.get('/api/v1/order').pipe( - map(({ activities }) => { - for (const activity of activities) { - activity.createdAt = parseISO(activity.createdAt); - activity.date = parseISO(activity.date); - } - return { activities }; - }) - ); - } - public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) { let params = new HttpParams(); diff --git a/libs/common/src/lib/types/account-with-value.type.ts b/libs/common/src/lib/types/account-with-value.type.ts index 7c0cca747..bc7577d91 100644 --- a/libs/common/src/lib/types/account-with-value.type.ts +++ b/libs/common/src/lib/types/account-with-value.type.ts @@ -1,7 +1,8 @@ -import { Account as AccountModel } from '@prisma/client'; +import { Account as AccountModel, Platform } from '@prisma/client'; export type AccountWithValue = AccountModel & { balanceInBaseCurrency: number; + Platform?: Platform; transactionCount: number; value: number; valueInBaseCurrency: number;