From 585f99e4df83a222b99a6e0ef9f46651bf99ff49 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 23 Jan 2022 11:39:30 +0100 Subject: [PATCH] Feature/add summary row to activities table (#645) * Add summary row to activities table * Update changelog --- CHANGELOG.md | 4 ++ apps/api/src/app/import/import.module.ts | 5 +- .../order/interfaces/activities.interface.ts | 10 ++++ apps/api/src/app/order/order.controller.ts | 15 ++++-- apps/api/src/app/order/order.module.ts | 2 + apps/api/src/app/order/order.service.ts | 25 +++++++-- .../src/app/portfolio/portfolio.service.ts | 22 ++++---- .../accounts-table.component.html | 2 +- .../transactions-page.component.ts | 13 ++--- .../transactions/transactions-page.html | 2 +- apps/client/src/app/services/data.service.ts | 15 +++--- .../activities-table.component.html | 54 ++++++++++++++++++- .../activities-table.component.scss | 19 +++++++ .../activities-table.component.ts | 46 ++++++++++++++-- 14 files changed, 192 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/app/order/interfaces/activities.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d61e7b508..40bfd330b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the footer row with total fees and total value to the activities table + ### Changed - Upgraded _Stripe_ dependencies diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index c7533980d..35781e499 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -1,5 +1,5 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; @@ -15,10 +15,11 @@ import { ImportService } from './import.service'; ConfigurationModule, DataGatheringModule, DataProviderModule, + OrderModule, PrismaModule, RedisCacheModule ], controllers: [ImportController], - providers: [CacheService, ImportService, OrderService] + providers: [CacheService, ImportService] }) export class ImportModule {} diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts new file mode 100644 index 000000000..e14adce0b --- /dev/null +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -0,0 +1,10 @@ +import { OrderWithAccount } from '@ghostfolio/common/types'; + +export interface Activities { + activities: Activity[]; +} + +export interface Activity extends OrderWithAccount { + feeInBaseCurrency: number; + valueInBaseCurrency: number; +} diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 052138e62..eaf01a4f0 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -23,6 +23,7 @@ import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { CreateOrderDto } from './create-order.dto'; +import { Activities } from './interfaces/activities.interface'; import { OrderService } from './order.service'; import { UpdateOrderDto } from './update-order.dto'; @@ -59,14 +60,16 @@ export class OrderController { @UseGuards(AuthGuard('jwt')) public async getAllOrders( @Headers('impersonation-id') impersonationId - ): Promise { + ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId( impersonationId, this.request.user.id ); + const userCurrency = this.request.user.Settings.currency; - let orders = await this.orderService.getOrders({ + let activities = await this.orderService.getOrders({ + userCurrency, includeDrafts: true, userId: impersonationUserId || this.request.user.id }); @@ -75,15 +78,17 @@ export class OrderController { impersonationUserId || this.userService.isRestrictedView(this.request.user) ) { - orders = nullifyValuesInObjects(orders, [ + activities = nullifyValuesInObjects(activities, [ 'fee', + 'feeInBaseCurrency', 'quantity', 'unitPrice', - 'value' + 'value', + 'valueInBaseCurrency' ]); } - return orders; + return { activities }; } @Get(':id') diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index 92a503d68..3e6f6fca8 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { Module } from '@nestjs/common'; @@ -16,6 +17,7 @@ import { OrderService } from './order.service'; ConfigurationModule, DataGatheringModule, DataProviderModule, + ExchangeRateDataModule, ImpersonationModule, PrismaModule, RedisCacheModule, diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 1654380ae..af386e209 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,5 +1,6 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -7,10 +8,13 @@ import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; +import { Activity } from './interfaces/activities.interface'; + @Injectable() export class OrderService { public constructor( private readonly cacheService: CacheService, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly dataGatheringService: DataGatheringService, private readonly prismaService: PrismaService ) {} @@ -86,12 +90,14 @@ export class OrderService { public async getOrders({ includeDrafts = false, types, + userCurrency, userId }: { includeDrafts?: boolean; types?: TypeOfOrder[]; + userCurrency: string; userId: string; - }) { + }): Promise { const where: Prisma.OrderWhereInput = { userId }; if (includeDrafts === false) { @@ -124,12 +130,21 @@ export class OrderService { orderBy: { date: 'asc' } }) ).map((order) => { + const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); + return { ...order, - value: new Big(order.quantity) - .mul(order.unitPrice) - .plus(order.fee) - .toNumber() + value, + feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + order.fee, + order.currency, + userCurrency + ), + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + order.currency, + userCurrency + ) }; }); } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 15170dcef..fbbd5db0f 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -388,11 +388,12 @@ export class PortfolioService { aImpersonationId: string, aSymbol: string ): Promise { + const userCurrency = this.request.user.Settings.currency; const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const orders = (await this.orderService.getOrders({ userId })).filter( - (order) => order.symbol === aSymbol - ); + const orders = ( + await this.orderService.getOrders({ userCurrency, userId }) + ).filter((order) => order.symbol === aSymbol); if (orders.length <= 0) { return { @@ -846,24 +847,25 @@ export class PortfolioService { } public async getSummary(aImpersonationId: string): Promise { - const currency = this.request.user.Settings.currency; + const userCurrency = this.request.user.Settings.currency; const userId = await this.getUserId(aImpersonationId, this.request.user.id); const performanceInformation = await this.getPerformance(aImpersonationId); const { balance } = await this.accountService.getCashDetails( userId, - currency + userCurrency ); const orders = await this.orderService.getOrders({ + userCurrency, userId }); const dividend = this.getDividend(orders).toNumber(); const fees = this.getFees(orders).toNumber(); const firstOrderDate = orders[0]?.date; - const totalBuy = this.getTotalByType(orders, currency, 'BUY'); - const totalSell = this.getTotalByType(orders, currency, 'SELL'); + const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); + const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); const committedFunds = new Big(totalBuy).sub(totalSell); @@ -895,8 +897,8 @@ export class PortfolioService { }: { cashDetails: CashDetails; investment: Big; - value: Big; userCurrency: string; + value: Big; }) { const cashPositions = {}; @@ -1025,8 +1027,11 @@ export class PortfolioService { transactionPoints: TransactionPoint[]; orders: OrderWithAccount[]; }> { + const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; + const orders = await this.orderService.getOrders({ includeDrafts, + userCurrency, userId, types: ['BUY', 'SELL'] }); @@ -1035,7 +1040,6 @@ export class PortfolioService { return { transactionPoints: [], orders: [] }; } - const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ currency: order.currency, dataSource: order.SymbolProfile?.dataSource ?? order.dataSource, 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 1871e7f3c..f08ea8430 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 @@ -15,7 +15,7 @@ >(Default) - Total + Total 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 3ccdb0921..8606cd395 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 @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { DataService } from '@ghostfolio/client/services/data.service'; @@ -28,6 +29,7 @@ import { ImportTransactionDialog } from './import-transaction-dialog/import-tran templateUrl: './transactions-page.html' }) export class TransactionsPageComponent implements OnDestroy, OnInit { + public activities: Activity[]; public defaultAccountId: string; public deviceType: string; public hasImpersonationId: boolean; @@ -35,7 +37,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { public hasPermissionToDeleteOrder: boolean; public hasPermissionToImportOrders: boolean; public routeQueryParams: Subscription; - public transactions: OrderModel[]; public user: User; private primaryDataSource: DataSource; @@ -65,8 +66,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { if (params['createDialog']) { this.openCreateTransactionDialog(); } else if (params['editDialog']) { - if (this.transactions) { - const transaction = this.transactions.find(({ id }) => { + if (this.activities) { + const transaction = this.activities.find(({ id }) => { return id === params['transactionId']; }); @@ -119,10 +120,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { this.dataService .fetchOrders() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((response) => { - this.transactions = response; + .subscribe(({ activities }) => { + this.activities = activities; - if (this.hasPermissionToCreateOrder && this.transactions?.length <= 0) { + if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) { this.router.navigate([], { queryParams: { createDialog: true } }); } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html index 6674025fe..db365c2d9 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html @@ -3,7 +3,7 @@

Activities

{ - return this.http.get('/api/order').pipe( - map((data) => { - for (const item of data) { - item.createdAt = parseISO(item.createdAt); - item.date = parseISO(item.date); + public fetchOrders(): Observable { + return this.http.get('/api/order').pipe( + map(({ activities }) => { + for (const activity of activities) { + activity.createdAt = parseISO(activity.createdAt); + activity.date = parseISO(activity.date); } - return data; + return { activities }; }) ); } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 10524d822..84d2c813d 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -58,6 +58,11 @@ > {{ dataSource.data.length - i }} + @@ -68,6 +73,7 @@ {{ element.date | date: defaultDateFormat }}
+ Total
@@ -93,6 +99,7 @@ {{ element.type }} + @@ -107,6 +114,7 @@ > + @@ -122,6 +130,9 @@ {{ element.currency }} + + {{ baseCurrency }} + @@ -143,6 +154,11 @@ > + @@ -164,6 +180,11 @@ > + @@ -176,7 +197,7 @@ > Fee - +
+ +
+ +
+
@@ -197,7 +227,7 @@ > Value - +
+ +
+ +
+
@@ -223,6 +262,11 @@ {{ element.Account?.name }} + @@ -276,6 +320,7 @@ + @@ -291,6 +336,11 @@ " [ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }" > + ; @ViewChild(MatSort) sort: MatSort; - public dataSource: MatTableDataSource = - new MatTableDataSource(); + public dataSource: MatTableDataSource = new MatTableDataSource(); public defaultDateFormat = DEFAULT_DATE_FORMAT; public displayedColumns = []; public endOfToday = endOfToday(); @@ -71,6 +72,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { public searchControl = new FormControl(); public searchKeywords: string[] = []; public separatorKeysCodes: number[] = [ENTER, COMMA]; + public totalFees: number; + public totalValue: number; private allFilters: string[]; private unsubscribeSubject = new Subject(); @@ -218,6 +221,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { ); this.filters$.next(this.allFilters); + + this.totalFees = this.getTotalFees(); + this.totalValue = this.getTotalValue(); } private getSearchableFieldValues(activities: OrderWithAccount[]): string[] { @@ -263,4 +269,36 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { return item !== undefined; }); } + + private getTotalFees() { + let totalFees = new Big(0); + + for (const activity of this.dataSource.filteredData) { + if (isNumber(activity.feeInBaseCurrency)) { + totalFees = totalFees.plus(activity.feeInBaseCurrency); + } else { + return null; + } + } + + return totalFees.toNumber(); + } + + private getTotalValue() { + let totalValue = new Big(0); + + for (const activity of this.dataSource.filteredData) { + if (isNumber(activity.valueInBaseCurrency)) { + if (activity.type === 'BUY') { + totalValue = totalValue.plus(activity.valueInBaseCurrency); + } else if (activity.type === 'SELL') { + totalValue = totalValue.minus(activity.valueInBaseCurrency); + } + } else { + return null; + } + } + + return totalValue.toNumber(); + } }