diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f8258a2..a5a8297e 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 the transactions to the position detail dialog + ## 1.96.0 - 27.12.2021 ### Changed diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 5281529b..da1c44c9 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -66,28 +66,21 @@ export class OrderController { this.request.user.id ); - let orders = await this.orderService.orders({ - include: { - Account: { - include: { - Platform: true - } - }, - SymbolProfile: { - select: { - name: true - } - } - }, - orderBy: { date: 'desc' }, - where: { userId: impersonationUserId || this.request.user.id } + let orders = await this.orderService.getOrders({ + includeDrafts: true, + userId: impersonationUserId || this.request.user.id }); if ( impersonationUserId || this.userService.isRestrictedView(this.request.user) ) { - orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); + orders = nullifyValuesInObjects(orders, [ + 'fee', + 'quantity', + 'unitPrice', + 'value' + ]); } return orders; diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 4fabb98f..eb58b889 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { DataSource, Order, Prisma } from '@prisma/client'; +import Big from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; @Injectable() @@ -82,7 +83,7 @@ export class OrderService { }); } - public getOrders({ + public async getOrders({ includeDrafts = false, userId }: { @@ -95,15 +96,29 @@ export class OrderService { where.isDraft = false; } - return this.orders({ - where, - include: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Account: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - SymbolProfile: true - }, - orderBy: { date: 'asc' } + return ( + await this.orders({ + where, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Account: { + include: { + Platform: true + } + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + SymbolProfile: true + }, + orderBy: { date: 'asc' } + }) + ).map((order) => { + return { + ...order, + value: new Big(order.quantity) + .mul(order.unitPrice) + .plus(order.fee) + .toNumber() + }; }); } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index 06923098..ddc5fbf3 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -1,3 +1,4 @@ +import { OrderWithAccount } from '@ghostfolio/common/types'; import { AssetClass, AssetSubClass } from '@prisma/client'; export interface PortfolioPositionDetail { @@ -16,6 +17,7 @@ export interface PortfolioPositionDetail { name: string; netPerformance: number; netPerformancePercent: number; + orders: OrderWithAccount[]; quantity: number; symbol: string; transactionCount: number; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 879b11bb..a0e93ea0 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -360,6 +360,7 @@ export class PortfolioController { 'grossPerformance', 'investment', 'netPerformance', + 'orders', 'quantity', 'value' ]); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index fea7c1bb..4bcf9565 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -388,6 +388,7 @@ export class PortfolioService { name: undefined, netPerformance: undefined, netPerformancePercent: undefined, + orders: [], quantity: undefined, symbol: aSymbol, transactionCount: undefined, @@ -521,6 +522,7 @@ export class PortfolioService { minPrice, name, netPerformance, + orders, transactionCount, averagePrice: averagePrice.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(), @@ -578,6 +580,7 @@ export class PortfolioService { maxPrice, minPrice, name, + orders, averagePrice: 0, currency: currentData[aSymbol]?.currency, firstBuyDate: undefined, diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index e0f43ff0..b5aa0b1b 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -1,4 +1,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { DataService } from '@ghostfolio/client/services/data.service'; import { RANGE, @@ -33,9 +36,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private route: ActivatedRoute, + private router: Router, private settingsStorageService: SettingsStorageService, private userService: UserService ) { + route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['positionDetailDialog'] && params['symbol']) { + this.openDialog(params['symbol']); + } + }); + this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { @@ -69,6 +83,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + private openDialog(aSymbol: string): void { + const dialogRef = this.dialog.open(PositionDetailDialog, { + autoFocus: false, + data: { + baseCurrency: this.user?.settings?.baseCurrency, + deviceType: this.deviceType, + locale: this.user?.settings?.locale, + symbol: aSymbol + }, + 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 update() { this.dataService .fetchPositions({ range: this.dateRange }) diff --git a/apps/client/src/app/components/home-holdings/home-holdings.module.ts b/apps/client/src/app/components/home-holdings/home-holdings.module.ts index c4108673..5d0a13ab 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.module.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.module.ts @@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; +import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { HomeHoldingsComponent } from './home-holdings.component'; @@ -12,6 +13,7 @@ import { HomeHoldingsComponent } from './home-holdings.component'; exports: [], imports: [ CommonModule, + GfPositionDetailDialogModule, GfPositionsModule, MatButtonModule, MatCardModule, diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index f9a2127a..fd43729f 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts @@ -3,11 +3,13 @@ import { ChangeDetectorRef, Component, Inject, - OnDestroy + OnDestroy, + OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DataService } from '@ghostfolio/client/services/data.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { OrderWithAccount } from '@ghostfolio/common/types'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { AssetSubClass } from '@prisma/client'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; @@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces'; templateUrl: 'position-detail-dialog.html', styleUrls: ['./position-detail-dialog.component.scss'] }) -export class PositionDetailDialog implements OnDestroy { +export class PositionDetailDialog implements OnDestroy, OnInit { public assetSubClass: AssetSubClass; public averagePrice: number; public benchmarkDataItems: LineChartItem[]; @@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy { public name: string; public netPerformance: number; public netPerformancePercent: number; + public orders: OrderWithAccount[]; public quantity: number; public quantityPrecision = 2; public symbol: string; @@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy { private dataService: DataService, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams - ) { + ) {} + + public ngOnInit(): void { this.dataService - .fetchPositionDetail(data.symbol) + .fetchPositionDetail(this.data.symbol) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe( ({ @@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy { name, netPerformance, netPerformancePercent, + orders, quantity, symbol, transactionCount, @@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy { this.name = name; this.netPerformance = netPerformance; this.netPerformancePercent = netPerformancePercent; + this.orders = orders; this.quantity = quantity; this.symbol = symbol; this.transactionCount = transactionCount; diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html index a20f4c89..bbf31bf7 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html @@ -124,6 +124,20 @@ + + (); - public constructor( - private dialog: MatDialog, - private route: ActivatedRoute, - private router: Router - ) { - route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((params) => { - if ( - params['positionDetailDialog'] && - params['symbol'] && - params['symbol'] === this.position?.symbol - ) { - this.openDialog(); - } - }); - } + public constructor() {} public ngOnInit() {} @@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - - private openDialog(): void { - const dialogRef = this.dialog.open(PositionDetailDialog, { - autoFocus: false, - data: { - baseCurrency: this.baseCurrency, - deviceType: this.deviceType, - locale: this.locale, - symbol: this.position?.symbol - }, - 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 }); - }); - } } diff --git a/apps/client/src/app/components/positions-table/positions-table.component.ts b/apps/client/src/app/components/positions-table/positions-table.component.ts index 6bb8f1d0..da6fe7f5 100644 --- a/apps/client/src/app/components/positions-table/positions-table.component.ts +++ b/apps/client/src/app/components/positions-table/positions-table.component.ts @@ -14,13 +14,12 @@ import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router } from '@angular/router'; +import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { AssetClass, Order as OrderModel } from '@prisma/client'; import { Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component'; - @Component({ selector: 'gf-positions-table', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/client/src/app/components/positions-table/positions-table.module.ts b/apps/client/src/app/components/positions-table/positions-table.module.ts index 1f32b91b..9461a890 100644 --- a/apps/client/src/app/components/positions-table/positions-table.module.ts +++ b/apps/client/src/app/components/positions-table/positions-table.module.ts @@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; import { RouterModule } from '@angular/router'; +import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; import { GfValueModule } from '@ghostfolio/ui/value'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { PositionsTableComponent } from './positions-table.component'; diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.html b/apps/client/src/app/components/transactions-table/transactions-table.component.html index 99ea5c7b..6f262616 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.html +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.html @@ -1,4 +1,8 @@ - + + + + Value + + +
+ +
+ +
+ Account @@ -254,12 +279,13 @@ *matRowDef="let row; columns: displayedColumns" mat-row (click)=" - !row.isDraft && + hasPermissionToOpenDetails && + !row.isDraft && onOpenPositionDialog({ symbol: row.symbol }) " - [ngClass]="{ 'is-draft': row.isDraft }" + [ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }" > diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.scss b/apps/client/src/app/components/transactions-table/transactions-table.component.scss index 965de25a..b63124cf 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.scss +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.scss @@ -24,10 +24,6 @@ } .mat-row { - &:not(.is-draft) { - cursor: pointer; - } - .type-badge { background-color: rgba(var(--palette-foreground-text), 0.05); border-radius: 1rem; diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.ts b/apps/client/src/app/components/transactions-table/transactions-table.component.ts index ab2f18e1..e22a0bcb 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.ts +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.ts @@ -17,18 +17,15 @@ import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatChipInputEvent } from '@angular/material/chips'; -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 { Router } from '@angular/router'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { endOfToday, format, isAfter } from 'date-fns'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component'; - const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...'; const SEARCH_STRING_SEPARATOR = ','; @@ -44,9 +41,12 @@ export class TransactionsTableComponent @Input() baseCurrency: string; @Input() deviceType: string; @Input() hasPermissionToCreateOrder: boolean; + @Input() hasPermissionToFilter = true; @Input() hasPermissionToImportOrders: boolean; + @Input() hasPermissionToOpenDetails = true; @Input() locale: string; @Input() showActions: boolean; + @Input() showSymbolColumn = true; @Input() transactions: OrderWithAccount[]; @Output() export = new EventEmitter(); @@ -77,21 +77,7 @@ export class TransactionsTableComponent private allFilters: string[]; private unsubscribeSubject = new Subject(); - public constructor( - private dialog: MatDialog, - private route: ActivatedRoute, - private router: Router - ) { - this.routeQueryParams = route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((params) => { - if (params['positionDetailDialog'] && params['symbol']) { - this.openPositionDialog({ - symbol: params['symbol'] - }); - } - }); - + public constructor(private router: Router) { this.searchControl.valueChanges .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((keyword) => { @@ -150,6 +136,7 @@ export class TransactionsTableComponent 'quantity', 'unitPrice', 'fee', + 'value', 'account' ]; @@ -157,6 +144,12 @@ export class TransactionsTableComponent this.displayedColumns.push('actions'); } + if (!this.showSymbolColumn) { + this.displayedColumns = this.displayedColumns.filter((column) => { + return column !== 'symbol'; + }); + } + this.isLoading = true; if (this.transactions) { @@ -210,27 +203,6 @@ export class TransactionsTableComponent this.transactionToClone.emit(aTransaction); } - public openPositionDialog({ symbol }: { symbol: string }): void { - const dialogRef = this.dialog.open(PositionDetailDialog, { - autoFocus: false, - data: { - symbol, - baseCurrency: this.baseCurrency, - deviceType: this.deviceType, - locale: this.locale - }, - 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 }); - }); - } - public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/transactions-table/transactions-table.module.ts b/apps/client/src/app/components/transactions-table/transactions-table.module.ts index c8bca3c6..86292812 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.module.ts +++ b/apps/client/src/app/components/transactions-table/transactions-table.module.ts @@ -14,7 +14,6 @@ import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info' import { GfValueModule } from '@ghostfolio/ui/value'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { TransactionsTableComponent } from './transactions-table.component'; @@ -24,7 +23,6 @@ import { TransactionsTableComponent } from './transactions-table.component'; imports: [ CommonModule, GfNoTransactionsInfoModule, - GfPositionDetailDialogModule, GfSymbolIconModule, GfSymbolModule, GfValueModule, 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 1178c8ae..59342f33 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 @@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; 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'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; @@ -73,6 +74,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { } else { this.router.navigate(['.'], { relativeTo: this.route }); } + } else if (params['positionDetailDialog'] && params['symbol']) { + this.openPositionDialog({ + symbol: params['symbol'] + }); } }); } @@ -250,6 +255,27 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } + public openPositionDialog({ symbol }: { symbol: string }): void { + const dialogRef = this.dialog.open(PositionDetailDialog, { + autoFocus: false, + data: { + symbol, + baseCurrency: this.user?.settings?.baseCurrency, + deviceType: this.deviceType, + locale: this.user?.settings?.locale + }, + 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 }); + }); + } + public openUpdateTransactionDialog({ accountId, currency, diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 586192db..6191fd9b 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -212,8 +212,17 @@ export class DataService { } public fetchPositionDetail(aSymbol: string) { - return this.http.get( - `/api/portfolio/position/${aSymbol}` + return this.http.get(`/api/portfolio/position/${aSymbol}`).pipe( + map((data) => { + if (data.orders) { + for (const order of data.orders) { + order.createdAt = parseISO(order.createdAt); + order.date = parseISO(order.date); + } + } + + return data; + }) ); }