From 8f6203d296f8270ea3c991d70910f9d36117df1d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Aug 2024 15:46:01 +0200 Subject: [PATCH] Feature/manage tags of holdings (#3630) * Manage tags of holdings * Update changelog --- CHANGELOG.md | 4 + apps/api/src/app/order/order.service.ts | 33 +++++++ .../src/app/portfolio/portfolio.controller.ts | 59 +++++++++--- .../src/app/portfolio/portfolio.service.ts | 21 +++- .../app/portfolio/update-holding-tags.dto.ts | 7 ++ apps/client/src/app/app.component.ts | 4 + .../holding-detail-dialog.component.ts | 95 ++++++++++++++++++- .../holding-detail-dialog.html | 46 ++++++++- .../interfaces/interfaces.ts | 1 + ...ate-or-update-activity-dialog.component.ts | 19 ++-- .../create-or-update-activity-dialog.html | 6 +- apps/client/src/app/services/data.service.ts | 14 ++- 12 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/app/portfolio/update-holding-tags.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 805821d7c..d8666cb74 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 support to manage tags of holdings in the holding detail dialog + ### Changed - Improved the color assignment in the chart of the holdings tab on the home page (experimental) diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index b743eb2b7..f7be3ba00 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -46,6 +46,39 @@ export class OrderService { private readonly symbolProfileService: SymbolProfileService ) {} + public async assignTags({ + dataSource, + symbol, + tags, + userId + }: { tags: Tag[]; userId: string } & UniqueAsset) { + const orders = await this.prismaService.order.findMany({ + where: { + userId, + SymbolProfile: { + dataSource, + symbol + } + } + }); + + return Promise.all( + orders.map(({ id }) => + this.prismaService.order.update({ + data: { + tags: { + // The set operation replaces all existing connections with the provided ones + set: tags.map(({ id }) => { + return { id }; + }) + } + }, + where: { id } + }) + ) + ); + } + public async createOrder( data: Prisma.OrderCreateInput & { accountId?: string; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 84d4ef532..3c7993c61 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,6 +1,7 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, @@ -29,7 +30,8 @@ import { } from '@ghostfolio/common/interfaces'; import { hasReadRestrictedAccessPermission, - isRestrictedView + isRestrictedView, + permissions } from '@ghostfolio/common/permissions'; import type { DateRange, @@ -38,12 +40,14 @@ import type { } from '@ghostfolio/common/types'; import { + Body, Controller, Get, Headers, HttpException, Inject, Param, + Put, Query, UseGuards, UseInterceptors, @@ -51,12 +55,13 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { AssetClass, AssetSubClass } from '@prisma/client'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioService } from './portfolio.service'; +import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @Controller('portfolio') export class PortfolioController { @@ -566,23 +571,23 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getPosition( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Param('dataSource') dataSource, - @Param('symbol') symbol + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string ): Promise { - const position = await this.portfolioService.getPosition( + const holding = await this.portfolioService.getPosition( dataSource, impersonationId, symbol ); - if (position) { - return position; + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); } - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); + return holding; } @Get('report') @@ -605,4 +610,36 @@ export class PortfolioController { return report; } + + @HasPermission(permissions.updateOrder) + @Put('position/:dataSource/:symbol/tags') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateHoldingTags( + @Body() data: UpdateHoldingTagsDto, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getPosition( + dataSource, + impersonationId, + symbol + ); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + await this.portfolioService.updateTags({ + dataSource, + impersonationId, + symbol, + tags: data.tags, + userId: this.request.user.id + }); + } } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 368c04595..67529cc67 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -59,7 +59,8 @@ import { DataSource, Order, Platform, - Prisma + Prisma, + Tag } from '@prisma/client'; import { Big } from 'big.js'; import { @@ -1304,6 +1305,24 @@ export class PortfolioService { }; } + public async updateTags({ + dataSource, + impersonationId, + symbol, + tags, + userId + }: { + dataSource: DataSource; + impersonationId: string; + symbol: string; + tags: Tag[]; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + + await this.orderService.assignTags({ dataSource, symbol, tags, userId }); + } + private async getCashPositions({ cashDetails, userCurrency, diff --git a/apps/api/src/app/portfolio/update-holding-tags.dto.ts b/apps/api/src/app/portfolio/update-holding-tags.dto.ts new file mode 100644 index 000000000..11efe189d --- /dev/null +++ b/apps/api/src/app/portfolio/update-holding-tags.dto.ts @@ -0,0 +1,7 @@ +import { Tag } from '@prisma/client'; +import { IsArray } from 'class-validator'; + +export class UpdateHoldingTagsDto { + @IsArray() + tags: Tag[]; +} diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 08cc915bd..4f1464408 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit { this.user?.permissions, permissions.reportDataGlitch ), + hasPermissionToUpdateOrder: + !this.hasImpersonationId && + hasPermission(this.user?.permissions, permissions.updateOrder) && + !user?.settings?.isRestrictedView, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 3ed2f13a5..5673cd0c0 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfValueComponent } from '@ghostfolio/ui/value'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ChangeDetectorRef, Component, + ElementRef, Inject, OnDestroy, - OnInit + OnInit, + ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; import { @@ -36,14 +44,15 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; import { Account, Tag } from '@prisma/client'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { Observable, of, Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; import { HoldingDetailDialogParams } from './interfaces/interfaces'; @@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; GfLineChartComponent, GfPortfolioProportionChartComponent, GfValueComponent, + MatAutocompleteModule, MatButtonModule, MatChipsModule, MatDialogModule, + MatFormFieldModule, MatTabsModule, NgxSkeletonLoaderModule ], @@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; templateUrl: 'holding-detail-dialog.html' }) export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { + @ViewChild('tagInput') tagInput: ElementRef; + + public activityForm: FormGroup; public accounts: Account[]; public activities: Activity[]; public assetClass: string; @@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public dividendInBaseCurrencyPrecision = 2; public dividendYieldPercentWithCurrencyEffect: number; public feeInBaseCurrency: number; + public filteredTagsObservable: Observable = of([]); public firstBuyDate: string; public historicalDataItems: LineChartItem[]; public investment: number; @@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public sectors: { [name: string]: { name: string; value: number }; }; + public separatorKeysCodes: number[] = [COMMA, ENTER]; public sortColumn = 'date'; public sortDirection: SortDirection = 'desc'; public SymbolProfile: EnhancedSymbolProfile; public tags: Tag[]; + public tagsAvailable: Tag[]; public totalItems: number; public transactionCount: number; public user: User; @@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { private dataService: DataService, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, + private formBuilder: FormBuilder, private userService: UserService ) {} public ngOnInit() { + const { tags } = this.dataService.fetchInfo(); + + this.activityForm = this.formBuilder.group({ + tags: [] + }); + + this.tagsAvailable = tags.map(({ id, name }) => { + return { + id, + name: translate(name) + }; + }); + + this.activityForm + .get('tags') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((tags) => { + this.dataService + .putHoldingTags({ + tags, + dataSource: this.data.dataSource, + symbol: this.data.symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + }); + this.dataService .fetchHoldingDetail({ dataSource: this.data.dataSource, @@ -248,12 +293,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.sectors = {}; this.SymbolProfile = SymbolProfile; + this.tags = tags.map(({ id, name }) => { return { id, name: translate(name) }; }); + + this.activityForm.setValue({ tags: this.tags }, { emitEvent: false }); + + this.filteredTagsObservable = this.activityForm.controls[ + 'tags' + ].valueChanges.pipe( + startWith(this.activityForm.get('tags').value), + map((aTags: Tag[] | null) => { + return aTags + ? this.filterTags(aTags) + : this.tagsAvailable.slice(); + }) + ); + this.transactionCount = transactionCount; this.totalItems = transactionCount; this.value = value; @@ -353,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } + public onAddTag(event: MatAutocompleteSelectedEvent) { + this.activityForm.get('tags').setValue([ + ...(this.activityForm.get('tags').value ?? []), + this.tagsAvailable.find(({ id }) => { + return id === event.option.value; + }) + ]); + + this.tagInput.nativeElement.value = ''; + } + public onClose() { this.dialogRef.close(); } @@ -377,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } + public onRemoveTag(aTag: Tag) { + this.activityForm.get('tags').setValue( + this.activityForm.get('tags').value.filter(({ id }) => { + return id !== aTag.id; + }) + ); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private filterTags(aTags: Tag[]) { + const tagIds = aTags.map(({ id }) => { + return id; + }); + + return this.tagsAvailable.filter(({ id }) => { + return !tagIds.includes(id); + }); + } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 9afeef709..b7474a7a3 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -325,7 +325,7 @@ @@ -375,7 +375,49 @@ - @if (tags?.length > 0) { +
+
+ + Tags + + @for (tag of activityForm.get('tags')?.value; track tag.id) { + + {{ tag.name }} + + + } + + + + @for (tag of filteredTagsObservable | async; track tag.id) { + + {{ tag.name }} + + } + + +
+
+ + @if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
Tags
diff --git a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts index c6cfce1ee..8178838ab 100644 --- a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts @@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams { deviceType: string; hasImpersonationId: boolean; hasPermissionToReportDataGlitch: boolean; + hasPermissionToUpdateOrder: boolean; locale: string; symbol: string; } diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index dedcdd4a9..323796bef 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { public isToday = isToday; public mode: 'create' | 'update'; public platforms: { id: string; name: string }[]; - public separatorKeysCodes: number[] = [ENTER, COMMA]; - public tags: Tag[] = []; + public separatorKeysCodes: number[] = [COMMA, ENTER]; + public tagsAvailable: Tag[] = []; public total = 0; public typesTranslationMap = new Map(); public Validators = Validators; @@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.currencies = currencies; this.defaultDateFormat = getDateFormatString(this.locale); this.platforms = platforms; - this.tags = tags.map(({ id, name }) => { + this.tagsAvailable = tags.map(({ id, name }) => { return { id, name: translate(name) @@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { ].valueChanges.pipe( startWith(this.activityForm.get('tags').value), map((aTags: Tag[] | null) => { - return aTags ? this.filterTags(aTags) : this.tags.slice(); + return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice(); }) ); @@ -441,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { public onAddTag(event: MatAutocompleteSelectedEvent) { this.activityForm.get('tags').setValue([ ...(this.activityForm.get('tags').value ?? []), - this.tags.find(({ id }) => { + this.tagsAvailable.find(({ id }) => { return id === event.option.value; }) ]); + this.tagInput.nativeElement.value = ''; } @@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { } private filterTags(aTags: Tag[]) { - const tagIds = aTags.map((tag) => { - return tag.id; + const tagIds = aTags.map(({ id }) => { + return id; }); - return this.tags.filter((tag) => { - return !tagIds.includes(tag.id); + return this.tagsAvailable.filter(({ id }) => { + return !tagIds.includes(id); }); } diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 56cb66fcd..7795688c0 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -378,11 +378,11 @@
-
+
Tags - @for (tag of activityForm.get('tags')?.value; track tag) { + @for (tag of activityForm.get('tags')?.value; track tag.id) { - @for (tag of filteredTagsObservable | async; track tag) { + @for (tag of filteredTagsObservable | async; track tag.id) { {{ tag.name }} diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 64e498d12..4f9fd7e20 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort'; import { AccountBalance, DataSource, - Order as OrderModel + Order as OrderModel, + Tag } from '@prisma/client'; import { format, parseISO } from 'date-fns'; import { cloneDeep, groupBy, isNumber } from 'lodash'; @@ -649,6 +650,17 @@ export class DataService { return this.http.put(`/api/v1/admin/settings/${key}`, aData); } + public putHoldingTags({ + dataSource, + symbol, + tags + }: { tags: Tag[] } & UniqueAsset) { + return this.http.put( + `/api/v1/portfolio/position/${dataSource}/${symbol}/tags`, + { tags } + ); + } + public putOrder(aOrder: UpdateOrderDto) { return this.http.put(`/api/v1/order/${aOrder.id}`, aOrder); }