Browse Source

Feature/add summary row to activities table (#645)

* Add summary row to activities table

* Update changelog
pull/646/head
Thomas Kaul 3 years ago
committed by GitHub
parent
commit
585f99e4df
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 5
      apps/api/src/app/import/import.module.ts
  3. 10
      apps/api/src/app/order/interfaces/activities.interface.ts
  4. 15
      apps/api/src/app/order/order.controller.ts
  5. 2
      apps/api/src/app/order/order.module.ts
  6. 25
      apps/api/src/app/order/order.service.ts
  7. 22
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 2
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  9. 13
      apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts
  10. 2
      apps/client/src/app/pages/portfolio/transactions/transactions-page.html
  11. 15
      apps/client/src/app/services/data.service.ts
  12. 54
      libs/ui/src/lib/activities-table/activities-table.component.html
  13. 19
      libs/ui/src/lib/activities-table/activities-table.component.scss
  14. 46
      libs/ui/src/lib/activities-table/activities-table.component.ts

4
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

5
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 {}

10
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;
}

15
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<OrderModel[]> {
): Promise<Activities> {
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')

2
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,

25
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<Activity[]> {
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
)
};
});
}

22
apps/api/src/app/portfolio/portfolio.service.ts

@ -388,11 +388,12 @@ export class PortfolioService {
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
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<PortfolioSummary> {
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,

2
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -15,7 +15,7 @@
>(Default)</span
>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
</ng-container>
<ng-container matColumnDef="currency">

13
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 } });
}

2
apps/client/src/app/pages/portfolio/transactions/transactions-page.html

@ -3,7 +3,7 @@
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3>
<gf-activities-table
[activities]="transactions"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"

15
apps/client/src/app/services/data.service.ts

@ -4,6 +4,7 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
@ -169,14 +170,14 @@ export class DataService {
);
}
public fetchOrders(): Observable<OrderModel[]> {
return this.http.get<any[]>('/api/order').pipe(
map((data) => {
for (const item of data) {
item.createdAt = parseISO(item.createdAt);
item.date = parseISO(item.date);
public fetchOrders(): Observable<Activities> {
return this.http.get<any>('/api/order').pipe(
map(({ activities }) => {
for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt);
activity.date = parseISO(activity.date);
}
return data;
return { activities };
})
);
}

54
libs/ui/src/lib/activities-table/activities-table.component.html

@ -58,6 +58,11 @@
>
{{ dataSource.data.length - i }}
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
@ -68,6 +73,7 @@
{{ element.date | date: defaultDateFormat }}
</div>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="type">
@ -93,6 +99,7 @@
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="symbol">
@ -107,6 +114,7 @@
>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="currency">
@ -122,6 +130,9 @@
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container>
<ng-container matColumnDef="quantity">
@ -143,6 +154,11 @@
></gf-value>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="unitPrice">
@ -164,6 +180,11 @@
></gf-value>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="fee">
@ -176,7 +197,7 @@
>
Fee
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px1" mat-cell>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
@ -185,6 +206,15 @@
></gf-value>
</div>
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalFees"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="value">
@ -197,7 +227,7 @@
>
Value
</th>
<td *matCellDef="let element" class="px1" mat-cell>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
@ -206,6 +236,15 @@
></gf-value>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalValue"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="account">
@ -223,6 +262,11 @@
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="actions">
@ -276,6 +320,7 @@
</button>
</mat-menu>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
@ -291,6 +336,11 @@
"
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
></tr>
</table>
<ngx-skeleton-loader

19
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -15,6 +15,16 @@
}
.mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th {
::ng-deep {
.mat-sort-header-container {
@ -55,6 +65,15 @@
}
.mat-table {
td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
.type-badge {
background-color: rgba(
var(--palette-foreground-text-dark),

46
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -7,7 +7,6 @@ import {
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
@ -20,9 +19,12 @@ import { MatChipInputEvent } from '@angular/material/chips';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js';
import { endOfToday, format, isAfter } from 'date-fns';
import { isNumber } from 'lodash';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -36,7 +38,7 @@ const SEARCH_STRING_SEPARATOR = ',';
templateUrl: './activities-table.component.html'
})
export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() activities: OrderWithAccount[];
@Input() activities: Activity[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@ -57,8 +59,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<OrderWithAccount> =
new MatTableDataSource();
public dataSource: MatTableDataSource<Activity> = 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<void>();
@ -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();
}
}

Loading…
Cancel
Save