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
|
||||
|
||||
### 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)
|
||||
|
@ -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;
|
||||
|
@ -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<PortfolioHoldingDetail> {
|
||||
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<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,
|
||||
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,
|
||||
|
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,
|
||||
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',
|
||||
|
@ -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<HTMLInputElement>;
|
||||
|
||||
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<Tag[]> = 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<GfHoldingDetailDialogComponent>,
|
||||
@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: <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
|
||||
.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -325,7 +325,7 @@
|
||||
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
class="mb-3"
|
||||
class="mb-5"
|
||||
[mat-stretch-tabs]="false"
|
||||
[ngClass]="{ 'd-none': !activities?.length }"
|
||||
>
|
||||
@ -375,7 +375,49 @@
|
||||
</mat-tab>
|
||||
</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="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
hasPermissionToUpdateOrder: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -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<Type, string>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -378,11 +378,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</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-label i18n>Tags</mat-label>
|
||||
<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
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -404,7 +404,7 @@
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag) {
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
|
@ -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<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) {
|
||||
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user