Feature/add support for wealth items (#666)

* Add support for wealth items

* Update changelog
This commit is contained in:
Thomas Kaul
2022-02-10 09:39:10 +01:00
committed by GitHub
parent 7af5cd244a
commit 76f70598e2
29 changed files with 474 additions and 192 deletions

View File

@@ -142,6 +142,17 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Items</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.items"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>

View File

@@ -6,11 +6,15 @@ import {
OnDestroy,
ViewChild
} from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client';
import { isUUID } from 'class-validator';
import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs';
import {
@@ -34,19 +38,15 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup;
public currencies: string[] = [];
public currentMarketPrice = null;
public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false;
public platforms: { id: string; name: string }[];
public searchSymbolCtrl = new FormControl(
{
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
},
Validators.required
);
public Validators = Validators;
private unsubscribeSubject = new Subject<void>();
@@ -54,6 +54,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
) {}
@@ -63,36 +64,105 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.currencies = currencies;
this.platforms = platforms;
this.filteredLookupItemsObservable =
this.searchSymbolCtrl.valueChanges.pipe(
startWith(''),
debounceTime(400),
distinctUntilChanged(),
switchMap((query: string) => {
if (isString(query)) {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required],
currency: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [
this.data.activity?.SymbolProfile?.dataSource,
Validators.required
],
date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [
{
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
},
Validators.required
],
type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required]
});
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol'
].valueChanges.pipe(
startWith(''),
debounceTime(400),
distinctUntilChanged(),
switchMap((query: string) => {
if (isString(query)) {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
return filteredLookupItemsObservable;
}
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return [];
})
);
return filteredLookupItemsObservable;
}
if (this.data.transaction.id) {
this.searchSymbolCtrl.disable();
return [];
})
);
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => {
if (type === 'ITEM') {
this.activityForm.controls['accountId'].removeValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1);
this.activityForm.controls['searchSymbol'].removeValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} else {
this.activityForm.controls['accountId'].setValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['dataSource'].setValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].removeValidators(
Validators.required
);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['searchSymbol'].setValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].setValue(this.data.activity?.type);
if (this.data.activity?.id) {
this.activityForm.controls['searchSymbol'].disable();
this.activityForm.controls['type'].disable();
}
if (this.data.transaction.symbol) {
if (this.data.activity?.symbol) {
this.dataService
.fetchSymbolItem({
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
dataSource: this.data.activity?.dataSource,
symbol: this.data.activity?.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
@@ -104,7 +174,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}
public applyCurrentMarketPrice() {
this.data.transaction.unitPrice = this.currentMarketPrice;
this.activityForm.patchValue({
unitPrice: this.currentMarketPrice
});
}
public displayFn(aLookupItem: LookupItem) {
@@ -113,17 +185,20 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return lookupItem.symbol === this.data.transaction.symbol;
return (
lookupItem.symbol ===
this.activityForm.controls['searchSymbol'].value.symbol
);
});
if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol);
} else {
this.searchSymbolCtrl.setErrors({ incorrect: true });
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.symbol = null;
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.symbol = null;
}
this.changeDetectorRef.markForCheck();
@@ -133,8 +208,32 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.dialogRef.close();
}
public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value,
currency: this.activityForm.controls['currency'].value,
date: this.activityForm.controls['date'].value,
dataSource: this.activityForm.controls['dataSource'].value,
fee: this.activityForm.controls['fee'].value,
quantity: this.activityForm.controls['quantity'].value,
symbol: isUUID(this.activityForm.controls['searchSymbol'].value.symbol)
? this.activityForm.controls['name'].value
: this.activityForm.controls['searchSymbol'].value.symbol,
type: this.activityForm.controls['type'].value,
unitPrice: this.activityForm.controls['unitPrice'].value
};
if (this.data.activity.id) {
(activity as UpdateOrderDto).id = this.data.activity.id;
}
this.dialogRef.close({ activity });
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.data.transaction.dataSource = event.option.value.dataSource;
this.activityForm.controls['dataSource'].setValue(
event.option.value.dataSource
);
this.updateSymbol(event.option.value.symbol);
}
@@ -146,20 +245,21 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
private updateSymbol(symbol: string) {
this.isLoading = true;
this.searchSymbolCtrl.setErrors(null);
this.activityForm.controls['searchSymbol'].setErrors(null);
this.activityForm.controls['searchSymbol'].setValue({ symbol });
this.data.transaction.symbol = symbol;
this.changeDetectorRef.markForCheck();
this.dataService
.fetchSymbolItem({
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
dataSource: this.activityForm.controls['dataSource'].value,
symbol: this.activityForm.controls['searchSymbol'].value.symbol
})
.pipe(
catchError(() => {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.unitPrice = null;
this.isLoading = false;
@@ -170,8 +270,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency;
this.data.transaction.dataSource = dataSource;
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice;
this.isLoading = false;

View File

@@ -1,31 +1,45 @@
<form #addTransactionForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update activity</h1>
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add activity</h1>
<form
class="d-flex flex-column h-100"
[formGroup]="activityForm"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="ITEM" i18n>ITEM</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': !activityForm.controls['accountId'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-select formControlName="accountId">
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div>
<div
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol or ISIN</mat-label>
<input
autocapitalize="off"
autocomplete="off"
autocorrect="off"
formControlName="searchSymbol"
matInput
required
[formControl]="searchSymbolCtrl"
[matAutocomplete]="autocomplete"
(blur)="onBlurSymbol()"
/>
@@ -48,26 +62,18 @@
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
</mat-form-field>
</div>
<div>
<div
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.transaction.type">
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
</mat-select>
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput />
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select
class="no-arrow"
disabled
name="currency"
required
[(value)]="data.transaction.currency"
>
<mat-select class="no-arrow" formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
@@ -77,26 +83,13 @@
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Data Source</mat-label>
<input
disabled
matInput
name="dataSource"
required
[(ngModel)]="data.transaction.dataSource"
/>
<input formControlName="dataSource" matInput />
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
disabled
matInput
name="date"
required
[matDatepicker]="date"
[(ngModel)]="data.transaction.date"
/>
<input formControlName="date" matInput [matDatepicker]="date" />
<mat-datepicker-toggle matSuffix [for]="date">
<ion-icon
class="text-muted"
@@ -110,31 +103,22 @@
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label>
<input
matInput
name="quantity"
required
type="number"
[(ngModel)]="data.transaction.quantity"
/>
<input formControlName="quantity" matInput type="number" />
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Unit Price</mat-label>
<input
matInput
name="unitPrice"
required
type="number"
[(ngModel)]="data.transaction.unitPrice"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
<input formControlName="unitPrice" matInput type="number" />
<span class="ml-2" matSuffix
>{{ activityForm.controls['currency'].value }}</span
>
<button
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
mat-icon-button
matSuffix
title="Apply current market price"
type="button"
(click)="applyCurrentMarketPrice()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
@@ -144,32 +128,28 @@
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input
matInput
name="fee"
required
type="number"
[(ngModel)]="data.transaction.fee"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
<input formControlName="fee" matInput type="number" />
<span class="ml-2" matSuffix
>{{ activityForm.controls['currency'].value }}</span
>
</mat-form-field>
</div>
</div>
<div class="d-flex" mat-dialog-actions>
<gf-value
class="flex-grow-1"
[currency]="data.transaction.currency"
[currency]="activityForm.controls['currency'].value"
[locale]="data.user?.settings?.locale"
[value]="data.transaction.fee + (data.transaction.quantity * data.transaction.unitPrice)"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0"
></gf-value>
<div>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.currency && data.transaction.symbol)"
[mat-dialog-close]="data"
type="submit"
[disabled]="!activityForm.valid"
>
Save
</button>

View File

@@ -1,9 +1,10 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client';
import { Account } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;
accounts: Account[];
transaction: Order;
activity: Activity;
user: User;
}

View File

@@ -132,8 +132,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
});
}
public onCloneTransaction(aTransaction: OrderModel) {
this.openCreateTransactionDialog(aTransaction);
public onCloneTransaction(aActivity: Activity) {
this.openCreateTransactionDialog(aActivity);
}
public onDeleteTransaction(aId: string) {
@@ -242,35 +242,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
});
}
public openUpdateTransactionDialog({
accountId,
currency,
dataSource,
date,
fee,
id,
quantity,
symbol,
type,
unitPrice
}: OrderModel): void {
public openUpdateTransactionDialog(activity: Activity): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
activity,
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
transaction: {
accountId,
currency,
dataSource,
date,
fee,
id,
quantity,
symbol,
type,
unitPrice
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
@@ -281,7 +259,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
const transaction: UpdateOrderDto = data?.activity;
if (transaction) {
this.dataService
@@ -324,7 +302,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
});
}
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
private openCreateTransactionDialog(aActivity?: Activity): void {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
@@ -336,15 +314,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
transaction: {
accountId: aTransaction?.accountId ?? this.defaultAccountId,
currency: aTransaction?.currency ?? null,
dataSource: aTransaction?.dataSource ?? null,
activity: {
...aActivity,
accountId: aActivity?.accountId ?? this.defaultAccountId,
date: new Date(),
id: null,
fee: 0,
quantity: null,
symbol: aTransaction?.symbol ?? null,
type: aTransaction?.type ?? 'BUY',
type: aActivity?.type ?? 'BUY',
unitPrice: null
},
user: this.user
@@ -357,7 +334,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction;
const transaction: CreateOrderDto = data?.activity;
if (transaction) {
this.dataService.postOrder(transaction).subscribe({

View File

@@ -6,7 +6,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { map, Observable } from 'rxjs';
import { Observable, map } from 'rxjs';
@Injectable({
providedIn: 'root'

View File

@@ -245,6 +245,8 @@ export class ImportTransactionsService {
return Type.BUY;
case 'dividend':
return Type.DIVIDEND;
case 'item':
return Type.ITEM;
case 'sell':
return Type.SELL;
default: