Feature/manage tags of holdings (#3630)
* Manage tags of holdings * Update changelog
This commit is contained in:
parent
2fa723dc3c
commit
8f6203d296
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to manage tags of holdings in the holding detail dialog
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||||
|
@ -46,6 +46,39 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
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(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -29,7 +30,8 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
hasReadRestrictedAccessPermission,
|
hasReadRestrictedAccessPermission,
|
||||||
isRestrictedView
|
isRestrictedView,
|
||||||
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
@ -38,12 +40,14 @@ import type {
|
|||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@ -51,12 +55,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -566,23 +571,23 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol: string
|
||||||
): Promise<PortfolioHoldingDetail> {
|
): Promise<PortfolioHoldingDetail> {
|
||||||
const position = await this.portfolioService.getPosition(
|
const holding = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (!holding) {
|
||||||
return position;
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpException(
|
return holding;
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@ -605,4 +610,36 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return report;
|
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<void> {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,8 @@ import {
|
|||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Platform,
|
Platform,
|
||||||
Prisma
|
Prisma,
|
||||||
|
Tag
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import {
|
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({
|
private async getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { IsArray } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateHoldingTagsDto {
|
||||||
|
@IsArray()
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.user?.permissions,
|
this.user?.permissions,
|
||||||
permissions.reportDataGlitch
|
permissions.reportDataGlitch
|
||||||
),
|
),
|
||||||
|
hasPermissionToUpdateOrder:
|
||||||
|
!this.hasImpersonationId &&
|
||||||
|
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||||
|
!user?.settings?.isRestrictedView,
|
||||||
locale: this.user?.settings?.locale
|
locale: this.user?.settings?.locale
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
|||||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatAutocompleteSelectedEvent
|
||||||
|
} from '@angular/material/autocomplete';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -36,14 +44,15 @@ import {
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatDialogRef
|
MatDialogRef
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { SortDirection } from '@angular/material/sort';
|
import { SortDirection } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { Account, Tag } from '@prisma/client';
|
import { Account, Tag } from '@prisma/client';
|
||||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
import { Subject } from 'rxjs';
|
import { Observable, of, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
GfLineChartComponent,
|
GfLineChartComponent,
|
||||||
GfPortfolioProportionChartComponent,
|
GfPortfolioProportionChartComponent,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
|
MatAutocompleteModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
NgxSkeletonLoaderModule
|
NgxSkeletonLoaderModule
|
||||||
],
|
],
|
||||||
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'holding-detail-dialog.html'
|
templateUrl: 'holding-detail-dialog.html'
|
||||||
})
|
})
|
||||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
public activityForm: FormGroup;
|
||||||
public accounts: Account[];
|
public accounts: Account[];
|
||||||
public activities: Activity[];
|
public activities: Activity[];
|
||||||
public assetClass: string;
|
public assetClass: string;
|
||||||
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public dividendInBaseCurrencyPrecision = 2;
|
public dividendInBaseCurrencyPrecision = 2;
|
||||||
public dividendYieldPercentWithCurrencyEffect: number;
|
public dividendYieldPercentWithCurrencyEffect: number;
|
||||||
public feeInBaseCurrency: number;
|
public feeInBaseCurrency: number;
|
||||||
|
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||||
public firstBuyDate: string;
|
public firstBuyDate: string;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public investment: number;
|
public investment: number;
|
||||||
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||||
public sortColumn = 'date';
|
public sortColumn = 'date';
|
||||||
public sortDirection: SortDirection = 'desc';
|
public sortDirection: SortDirection = 'desc';
|
||||||
public SymbolProfile: EnhancedSymbolProfile;
|
public SymbolProfile: EnhancedSymbolProfile;
|
||||||
public tags: Tag[];
|
public tags: Tag[];
|
||||||
|
public tagsAvailable: Tag[];
|
||||||
public totalItems: number;
|
public totalItems: number;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
|
const { tags } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.activityForm = this.formBuilder.group({
|
||||||
|
tags: <string[]>[]
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
this.dataService
|
||||||
.fetchHoldingDetail({
|
.fetchHoldingDetail({
|
||||||
dataSource: this.data.dataSource,
|
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.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.sectors = {};
|
||||||
this.SymbolProfile = SymbolProfile;
|
this.SymbolProfile = SymbolProfile;
|
||||||
|
|
||||||
this.tags = tags.map(({ id, name }) => {
|
this.tags = tags.map(({ id, name }) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: translate(name)
|
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.transactionCount = transactionCount;
|
||||||
this.totalItems = transactionCount;
|
this.totalItems = transactionCount;
|
||||||
this.value = value;
|
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() {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
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() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private filterTags(aTags: Tag[]) {
|
||||||
|
const tagIds = aTags.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.tagsAvailable.filter(({ id }) => {
|
||||||
|
return !tagIds.includes(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,7 +325,7 @@
|
|||||||
|
|
||||||
<mat-tab-group
|
<mat-tab-group
|
||||||
animationDuration="0"
|
animationDuration="0"
|
||||||
class="mb-3"
|
class="mb-5"
|
||||||
[mat-stretch-tabs]="false"
|
[mat-stretch-tabs]="false"
|
||||||
[ngClass]="{ 'd-none': !activities?.length }"
|
[ngClass]="{ 'd-none': !activities?.length }"
|
||||||
>
|
>
|
||||||
@ -375,7 +375,49 @@
|
|||||||
</mat-tab>
|
</mat-tab>
|
||||||
</mat-tab-group>
|
</mat-tab-group>
|
||||||
|
|
||||||
@if (tags?.length > 0) {
|
<div
|
||||||
|
class="row"
|
||||||
|
[ngClass]="{
|
||||||
|
'd-none': !data.hasPermissionToUpdateOrder
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="col">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label i18n>Tags</mat-label>
|
||||||
|
<mat-chip-grid #tagsChipList>
|
||||||
|
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||||
|
<mat-chip-row
|
||||||
|
matChipRemove
|
||||||
|
[removable]="true"
|
||||||
|
(removed)="onRemoveTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
||||||
|
</mat-chip-row>
|
||||||
|
}
|
||||||
|
<input
|
||||||
|
#tagInput
|
||||||
|
name="close-outline"
|
||||||
|
[matAutocomplete]="autocompleteTags"
|
||||||
|
[matChipInputFor]="tagsChipList"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
/>
|
||||||
|
</mat-chip-grid>
|
||||||
|
<mat-autocomplete
|
||||||
|
#autocompleteTags="matAutocomplete"
|
||||||
|
(optionSelected)="onAddTag($event)"
|
||||||
|
>
|
||||||
|
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||||
|
<mat-option [value]="tag.id">
|
||||||
|
{{ tag.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="h5" i18n>Tags</div>
|
<div class="h5" i18n>Tags</div>
|
||||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
|||||||
deviceType: string;
|
deviceType: string;
|
||||||
hasImpersonationId: boolean;
|
hasImpersonationId: boolean;
|
||||||
hasPermissionToReportDataGlitch: boolean;
|
hasPermissionToReportDataGlitch: boolean;
|
||||||
|
hasPermissionToUpdateOrder: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
public isToday = isToday;
|
public isToday = isToday;
|
||||||
public mode: 'create' | 'update';
|
public mode: 'create' | 'update';
|
||||||
public platforms: { id: string; name: string }[];
|
public platforms: { id: string; name: string }[];
|
||||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||||
public tags: Tag[] = [];
|
public tagsAvailable: Tag[] = [];
|
||||||
public total = 0;
|
public total = 0;
|
||||||
public typesTranslationMap = new Map<Type, string>();
|
public typesTranslationMap = new Map<Type, string>();
|
||||||
public Validators = Validators;
|
public Validators = Validators;
|
||||||
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
this.tags = tags.map(({ id, name }) => {
|
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: translate(name)
|
name: translate(name)
|
||||||
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
].valueChanges.pipe(
|
].valueChanges.pipe(
|
||||||
startWith(this.activityForm.get('tags').value),
|
startWith(this.activityForm.get('tags').value),
|
||||||
map((aTags: Tag[] | null) => {
|
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) {
|
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||||
this.activityForm.get('tags').setValue([
|
this.activityForm.get('tags').setValue([
|
||||||
...(this.activityForm.get('tags').value ?? []),
|
...(this.activityForm.get('tags').value ?? []),
|
||||||
this.tags.find(({ id }) => {
|
this.tagsAvailable.find(({ id }) => {
|
||||||
return id === event.option.value;
|
return id === event.option.value;
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.tagInput.nativeElement.value = '';
|
this.tagInput.nativeElement.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterTags(aTags: Tag[]) {
|
private filterTags(aTags: Tag[]) {
|
||||||
const tagIds = aTags.map((tag) => {
|
const tagIds = aTags.map(({ id }) => {
|
||||||
return tag.id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.tags.filter((tag) => {
|
return this.tagsAvailable.filter(({ id }) => {
|
||||||
return !tagIds.includes(tag.id);
|
return !tagIds.includes(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -378,11 +378,11 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
|
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Tags</mat-label>
|
<mat-label i18n>Tags</mat-label>
|
||||||
<mat-chip-grid #tagsChipList>
|
<mat-chip-grid #tagsChipList>
|
||||||
@for (tag of activityForm.get('tags')?.value; track tag) {
|
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||||
<mat-chip-row
|
<mat-chip-row
|
||||||
matChipRemove
|
matChipRemove
|
||||||
[removable]="true"
|
[removable]="true"
|
||||||
@ -404,7 +404,7 @@
|
|||||||
#autocompleteTags="matAutocomplete"
|
#autocompleteTags="matAutocomplete"
|
||||||
(optionSelected)="onAddTag($event)"
|
(optionSelected)="onAddTag($event)"
|
||||||
>
|
>
|
||||||
@for (tag of filteredTagsObservable | async; track tag) {
|
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||||
<mat-option [value]="tag.id">
|
<mat-option [value]="tag.id">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
|
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
|
|||||||
import {
|
import {
|
||||||
AccountBalance,
|
AccountBalance,
|
||||||
DataSource,
|
DataSource,
|
||||||
Order as OrderModel
|
Order as OrderModel,
|
||||||
|
Tag
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||||
@ -649,6 +650,17 @@ export class DataService {
|
|||||||
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public putHoldingTags({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
tags
|
||||||
|
}: { tags: Tag[] } & UniqueAsset) {
|
||||||
|
return this.http.put<void>(
|
||||||
|
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
|
||||||
|
{ tags }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public putOrder(aOrder: UpdateOrderDto) {
|
public putOrder(aOrder: UpdateOrderDto) {
|
||||||
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user