Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 |
35
CHANGELOG.md
35
CHANGELOG.md
@ -5,6 +5,41 @@ 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).
|
||||
|
||||
## 1.62.0 - 17.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation message of the import functionality for transactions
|
||||
|
||||
## 1.61.0 - 15.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the import functionality for transactions by `csv` files
|
||||
- Introduced the primary data source
|
||||
|
||||
### Changed
|
||||
|
||||
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
|
||||
|
||||
## 1.60.0 - 13.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation of the import functionality for transactions
|
||||
- Valid data types
|
||||
- Maximum number of orders
|
||||
- No duplicate orders
|
||||
- Data provider service returns data for the `dataSource` / `symbol` pair
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the page layouts
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the broken line charts showing value labels
|
||||
|
||||
## 1.59.0 - 11.10.2021
|
||||
|
||||
### Added
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Order } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
orders: Partial<Order>[];
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
orders: Order[];
|
||||
}
|
||||
|
@ -42,7 +42,10 @@ export class ImportController {
|
||||
console.error(error);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
{
|
||||
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
message: [error.message]
|
||||
},
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(private readonly orderService: OrderService) {}
|
||||
private static MAX_ORDERS_TO_IMPORT = 20;
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
@ -14,7 +20,10 @@ export class ImportService {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await this.validateOrders({ orders, userId });
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
@ -25,6 +34,11 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -37,4 +51,53 @@ export class ImportService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateOrders({
|
||||
orders,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}) {
|
||||
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
||||
throw new Error('Too many transactions');
|
||||
}
|
||||
|
||||
const existingOrders = await this.orderService.orders({
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of orders.entries()) {
|
||||
const duplicateOrder = existingOrders.find((order) => {
|
||||
return (
|
||||
order.currency === currency &&
|
||||
order.dataSource === dataSource &&
|
||||
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
||||
order.fee === fee &&
|
||||
order.quantity === quantity &&
|
||||
order.symbol === symbol &&
|
||||
order.type === type &&
|
||||
order.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateOrder) {
|
||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||
}
|
||||
|
||||
const result = await this.dataProviderService.get([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (result[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
@ -16,6 +17,7 @@ export class InfoService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
@ -60,6 +62,7 @@ export class InfoService {
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
@ -8,7 +8,7 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
@ -23,7 +23,7 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(Type, { each: true })
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
|
@ -2,7 +2,6 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
@ -27,12 +26,15 @@ export class CurrentRateService {
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{ symbol, dataSource: DataSource.YAHOO }
|
||||
{
|
||||
symbol,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||
}
|
||||
]);
|
||||
return {
|
||||
symbol,
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||
symbol: symbol
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -199,6 +199,10 @@ export class DataProviderService {
|
||||
};
|
||||
}
|
||||
|
||||
public getPrimaryDataSource(): DataSource {
|
||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
switch (providerName) {
|
||||
case DataSource.ALPHA_VANTAGE:
|
||||
|
@ -210,7 +210,7 @@ export class ExchangeRateDataService {
|
||||
return {
|
||||
currency1: baseCurrency,
|
||||
currency2: currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||
symbol: `${baseCurrency}${currency}`
|
||||
};
|
||||
});
|
||||
|
@ -28,7 +28,10 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
|
||||
<footer
|
||||
*ngIf="currentRoute === 'start'"
|
||||
class="footer d-flex justify-content-center w-100"
|
||||
>
|
||||
<div class="container text-center">
|
||||
<div>
|
||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
|
@ -101,7 +101,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
return throwError('');
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -11,9 +11,10 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-about-page',
|
||||
templateUrl: './about-page.html',
|
||||
styleUrls: ['./about-page.scss']
|
||||
styleUrls: ['./about-page.scss'],
|
||||
templateUrl: './about-page.html'
|
||||
})
|
||||
export class AboutPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
|
@ -20,9 +20,10 @@ import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-account-page',
|
||||
templateUrl: './account-page.html',
|
||||
styleUrls: ['./account-page.scss']
|
||||
styleUrls: ['./account-page.scss'],
|
||||
templateUrl: './account-page.html'
|
||||
})
|
||||
export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||
|
@ -16,9 +16,10 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-accounts-page',
|
||||
templateUrl: './accounts-page.html',
|
||||
styleUrls: ['./accounts-page.scss']
|
||||
styleUrls: ['./accounts-page.scss'],
|
||||
templateUrl: './accounts-page.html'
|
||||
})
|
||||
export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
public accounts: AccountModel[];
|
||||
|
@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-admin-page',
|
||||
templateUrl: './admin-page.html',
|
||||
styleUrls: ['./admin-page.scss']
|
||||
styleUrls: ['./admin-page.scss'],
|
||||
templateUrl: './admin-page.html'
|
||||
})
|
||||
export class AdminPageComponent implements OnDestroy, OnInit {
|
||||
public dataGatheringInProgress: boolean;
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-hallo-ghostfolio-page',
|
||||
styleUrls: ['./hallo-ghostfolio-page.scss'],
|
||||
templateUrl: './hallo-ghostfolio-page.html'
|
||||
})
|
||||
export class HalloGhostfolioPageComponent {}
|
||||
|
@ -139,7 +139,7 @@
|
||||
Thomas von Ghostfolio
|
||||
</p>
|
||||
</section>
|
||||
<section class="my-5">
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-hello-ghostfolio-page',
|
||||
styleUrls: ['./hello-ghostfolio-page.scss'],
|
||||
templateUrl: './hello-ghostfolio-page.html'
|
||||
})
|
||||
export class HelloGhostfolioPageComponent {}
|
||||
|
@ -134,7 +134,7 @@
|
||||
Thomas from Ghostfolio
|
||||
</p>
|
||||
</section>
|
||||
<section class="my-5">
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -36,8 +36,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-home-page',
|
||||
templateUrl: './home-page.html',
|
||||
styleUrls: ['./home-page.scss']
|
||||
styleUrls: ['./home-page.scss'],
|
||||
templateUrl: './home-page.html'
|
||||
})
|
||||
export class HomePageComponent implements OnDestroy, OnInit {
|
||||
@HostBinding('class.with-create-account-container') get isDemo() {
|
||||
|
@ -7,9 +7,10 @@ import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-landing-page',
|
||||
templateUrl: './landing-page.html',
|
||||
styleUrls: ['./landing-page.scss']
|
||||
styleUrls: ['./landing-page.scss'],
|
||||
templateUrl: './landing-page.html'
|
||||
})
|
||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
|
@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-allocations-page',
|
||||
templateUrl: './allocations-page.html',
|
||||
styleUrls: ['./allocations-page.scss']
|
||||
styleUrls: ['./allocations-page.scss'],
|
||||
templateUrl: './allocations-page.html'
|
||||
})
|
||||
export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
public accounts: {
|
||||
|
@ -10,9 +10,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-analysis-page',
|
||||
templateUrl: './analysis-page.html',
|
||||
styleUrls: ['./analysis-page.scss']
|
||||
styleUrls: ['./analysis-page.scss'],
|
||||
templateUrl: './analysis-page.html'
|
||||
})
|
||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public accounts: {
|
||||
|
@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-portfolio-page',
|
||||
templateUrl: './portfolio-page.html',
|
||||
styleUrls: ['./portfolio-page.scss']
|
||||
styleUrls: ['./portfolio-page.scss'],
|
||||
templateUrl: './portfolio-page.html'
|
||||
})
|
||||
export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
|
@ -5,9 +5,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-report-page',
|
||||
templateUrl: './report-page.html',
|
||||
styleUrls: ['./report-page.scss']
|
||||
styleUrls: ['./report-page.scss'],
|
||||
templateUrl: './report-page.html'
|
||||
})
|
||||
export class ReportPageComponent implements OnDestroy, OnInit {
|
||||
public accountClusterRiskRules: PortfolioReportRule[];
|
||||
|
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class CreateOrUpdateTransactionDialogModule {}
|
||||
export class GfCreateOrUpdateTransactionDialogModule {}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Account, Order } from '@prisma/client';
|
||||
import { Order } from '@prisma/client';
|
||||
|
||||
export interface CreateOrUpdateTransactionDialogParams {
|
||||
accountId: string;
|
||||
|
@ -0,0 +1,50 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { ImportTransactionDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-import-transaction-dialog',
|
||||
styleUrls: ['./import-transaction-dialog.scss'],
|
||||
templateUrl: 'import-transaction-dialog.html'
|
||||
})
|
||||
export class ImportTransactionDialog implements OnDestroy {
|
||||
public details: any[] = [];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
|
||||
public dialogRef: MatDialogRef<ImportTransactionDialog>
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
for (const message of this.data.messages) {
|
||||
if (message.includes('orders.')) {
|
||||
let [index] = message.split(' ');
|
||||
index = index.replace('orders.', '');
|
||||
[index] = index.split('.');
|
||||
|
||||
this.details.push(this.data.orders[index]);
|
||||
} else {
|
||||
this.details.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
title="Import Transactions Error"
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-header>
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel
|
||||
*ngFor="let message of data.messages; let i = index"
|
||||
[disabled]="!details[i]"
|
||||
>
|
||||
<mat-expansion-panel-header class="pl-1">
|
||||
<mat-panel-title>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex mr-2">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
</div>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<pre
|
||||
*ngIf="details[i]"
|
||||
class="m-0"
|
||||
><code>{{ details[i] | json }}</code></pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-footer>
|
@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
|
||||
import { ImportTransactionDialog } from './import-transaction-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ImportTransactionDialog],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatExpansionModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfImportTransactionDialogModule {}
|
@ -0,0 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-expansion-panel {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
|
||||
.mat-expansion-panel-header {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface ImportTransactionDialogParams {
|
||||
deviceType: string;
|
||||
messages: string[];
|
||||
orders: any[];
|
||||
}
|
@ -6,23 +6,27 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
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';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { EMPTY, Subject, Subscription } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
|
||||
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-transactions-page',
|
||||
templateUrl: './transactions-page.html',
|
||||
styleUrls: ['./transactions-page.scss']
|
||||
styleUrls: ['./transactions-page.scss'],
|
||||
templateUrl: './transactions-page.html'
|
||||
})
|
||||
export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public defaultAccountId: string;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
@ -32,6 +36,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public transactions: OrderModel[];
|
||||
public user: User;
|
||||
|
||||
private primaryDataSource: DataSource;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
@ -43,11 +48,15 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private importTransactionsService: ImportTransactionsService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private snackBar: MatSnackBar,
|
||||
private userService: UserService
|
||||
) {
|
||||
const { primaryDataSource } = this.dataService.fetchInfo();
|
||||
this.primaryDataSource = primaryDataSource;
|
||||
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
@ -55,8 +64,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
this.openCreateTransactionDialog();
|
||||
} else if (params['editDialog']) {
|
||||
if (this.transactions) {
|
||||
const transaction = this.transactions.find((transaction) => {
|
||||
return transaction.id === params['transactionId'];
|
||||
const transaction = this.transactions.find(({ id }) => {
|
||||
return id === params['transactionId'];
|
||||
});
|
||||
|
||||
this.openUpdateTransactionDialog(transaction);
|
||||
@ -93,6 +102,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.defaultAccountId = this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
@ -157,9 +170,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
public onImport() {
|
||||
const input = document.createElement('input');
|
||||
input.accept = 'application/JSON, .csv';
|
||||
input.type = 'file';
|
||||
|
||||
input.onchange = (event) => {
|
||||
this.snackBar.open('⏳ Importing data...');
|
||||
|
||||
// Getting the file reference
|
||||
const file = (event.target as HTMLInputElement).files[0];
|
||||
|
||||
@ -167,35 +183,51 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
reader.onload = async (readerEvent) => {
|
||||
const fileContent = readerEvent.target.result as string;
|
||||
|
||||
try {
|
||||
const content = JSON.parse(readerEvent.target.result as string);
|
||||
if (file.type === 'application/json') {
|
||||
const content = JSON.parse(fileContent);
|
||||
try {
|
||||
await this.importTransactionsService.importJson({
|
||||
content: content.orders,
|
||||
defaultAccountId: this.defaultAccountId
|
||||
});
|
||||
|
||||
this.snackBar.open('⏳ Importing data...');
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
this.handleImportError({ error, orders: content.orders });
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.postImport({
|
||||
orders: content.orders
|
||||
})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
this.handleImportError(error);
|
||||
return;
|
||||
} else if (file.type === 'text/csv') {
|
||||
try {
|
||||
await this.importTransactionsService.importCsv({
|
||||
fileContent,
|
||||
defaultAccountId: this.defaultAccountId,
|
||||
primaryDataSource: this.primaryDataSource
|
||||
});
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.fetchOrders();
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
this.handleImportError({
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
},
|
||||
orders: error?.orders ?? []
|
||||
});
|
||||
}
|
||||
|
||||
this.snackBar.open('✅ Import has been completed', undefined, {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
this.handleImportError(error);
|
||||
this.handleImportError({
|
||||
error: { error: { message: ['Unexpected format'] } },
|
||||
orders: []
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -281,20 +313,32 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
a.click();
|
||||
}
|
||||
|
||||
private handleImportError(aError: unknown) {
|
||||
console.error(aError);
|
||||
this.snackBar.open('❌ Oops, something went wrong...');
|
||||
private handleImportError({ error, orders }: { error: any; orders: any[] }) {
|
||||
this.snackBar.dismiss();
|
||||
|
||||
this.dialog.open(ImportTransactionDialog, {
|
||||
data: {
|
||||
orders,
|
||||
deviceType: this.deviceType,
|
||||
messages: error?.error?.message
|
||||
},
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
}
|
||||
|
||||
private handleImportSuccess() {
|
||||
this.fetchOrders();
|
||||
|
||||
this.snackBar.open('✅ Import has been completed', undefined, {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
data: {
|
||||
transaction: {
|
||||
accountId:
|
||||
aTransaction?.accountId ??
|
||||
this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id,
|
||||
accountId: aTransaction?.accountId ?? this.defaultAccountId,
|
||||
currency: aTransaction?.currency ?? null,
|
||||
dataSource: aTransaction?.dataSource ?? null,
|
||||
date: new Date(),
|
||||
|
@ -4,8 +4,10 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
|
||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||
|
||||
import { CreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
|
||||
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
|
||||
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
|
||||
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
|
||||
import { TransactionsPageComponent } from './transactions-page.component';
|
||||
|
||||
@ -14,14 +16,15 @@ import { TransactionsPageComponent } from './transactions-page.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
CreateOrUpdateTransactionDialogModule,
|
||||
GfCreateOrUpdateTransactionDialogModule,
|
||||
GfImportTransactionDialogModule,
|
||||
GfTransactionsTableModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
RouterModule,
|
||||
TransactionsPageRoutingModule
|
||||
],
|
||||
providers: [],
|
||||
providers: [ImportTransactionsService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class TransactionsPageModule {}
|
||||
|
@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-pricing-page',
|
||||
templateUrl: './pricing-page.html',
|
||||
styleUrls: ['./pricing-page.scss']
|
||||
styleUrls: ['./pricing-page.scss'],
|
||||
templateUrl: './pricing-page.html'
|
||||
})
|
||||
export class PricingPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
|
@ -12,9 +12,10 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-register-page',
|
||||
templateUrl: './register-page.html',
|
||||
styleUrls: ['./register-page.scss']
|
||||
styleUrls: ['./register-page.scss'],
|
||||
templateUrl: './register-page.html'
|
||||
})
|
||||
export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
|
@ -2,9 +2,10 @@ import { Component, OnInit } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-resources-page',
|
||||
templateUrl: './resources-page.html',
|
||||
styleUrls: ['./resources-page.scss']
|
||||
styleUrls: ['./resources-page.scss'],
|
||||
templateUrl: './resources-page.html'
|
||||
})
|
||||
export class ResourcesPageComponent implements OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
@ -6,9 +6,10 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-webauthn-page',
|
||||
templateUrl: './webauthn-page.html',
|
||||
styleUrls: ['./webauthn-page.scss']
|
||||
styleUrls: ['./webauthn-page.scss'],
|
||||
templateUrl: './webauthn-page.html'
|
||||
})
|
||||
export class WebauthnPageComponent implements OnDestroy, OnInit {
|
||||
public hasError = false;
|
||||
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
@ -39,18 +39,13 @@ import { cloneDeep } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { SettingsStorageService } from './settings-storage.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DataService {
|
||||
private info: InfoItem;
|
||||
|
||||
public constructor(
|
||||
private http: HttpClient,
|
||||
private settingsStorageService: SettingsStorageService
|
||||
) {}
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public createCheckoutSession({
|
||||
couponId,
|
||||
@ -199,10 +194,6 @@ export class DataService {
|
||||
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
||||
}
|
||||
|
||||
public postImport(aImportData: ImportDataDto) {
|
||||
return this.http.post<void>('/api/import', aImportData);
|
||||
}
|
||||
|
||||
public postOrder(aOrder: CreateOrderDto) {
|
||||
return this.http.post<OrderModel>(`/api/order`, aOrder);
|
||||
}
|
||||
|
254
apps/client/src/app/services/import-transactions.service.ts
Normal file
254
apps/client/src/app/services/import-transactions.service.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { parse } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ImportTransactionsService {
|
||||
private static CURRENCY_KEYS = ['ccy', 'currency'];
|
||||
private static DATE_KEYS = ['date'];
|
||||
private static FEE_KEYS = ['commission', 'fee'];
|
||||
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares'];
|
||||
private static SYMBOL_KEYS = ['code', 'symbol'];
|
||||
private static TYPE_KEYS = ['action', 'type'];
|
||||
private static UNIT_PRICE_KEYS = ['price', 'unitprice', 'value'];
|
||||
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public async importCsv({
|
||||
defaultAccountId,
|
||||
fileContent,
|
||||
primaryDataSource
|
||||
}: {
|
||||
defaultAccountId: string;
|
||||
fileContent: string;
|
||||
primaryDataSource: DataSource;
|
||||
}) {
|
||||
const content = csvToJson(fileContent, {
|
||||
dynamicTyping: true,
|
||||
header: true,
|
||||
skipEmptyLines: true
|
||||
}).data;
|
||||
|
||||
const orders: CreateOrderDto[] = [];
|
||||
|
||||
for (const [index, item] of content.entries()) {
|
||||
orders.push({
|
||||
accountId: defaultAccountId,
|
||||
currency: this.parseCurrency({ content, index, item }),
|
||||
dataSource: primaryDataSource,
|
||||
date: this.parseDate({ content, index, item }),
|
||||
fee: this.parseFee({ content, index, item }),
|
||||
quantity: this.parseQuantity({ content, index, item }),
|
||||
symbol: this.parseSymbol({ content, index, item }),
|
||||
type: this.parseType({ content, index, item }),
|
||||
unitPrice: this.parseUnitPrice({ content, index, item })
|
||||
});
|
||||
}
|
||||
|
||||
await this.importJson({ defaultAccountId, content: orders });
|
||||
}
|
||||
|
||||
public importJson({
|
||||
content,
|
||||
defaultAccountId
|
||||
}: {
|
||||
content: CreateOrderDto[];
|
||||
defaultAccountId: string;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.postImport({
|
||||
orders: content.map((order) => {
|
||||
return { ...order, accountId: defaultAccountId };
|
||||
})
|
||||
})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
reject(error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private lowercaseKeys(aObject: any) {
|
||||
return Object.keys(aObject).reduce((acc, key) => {
|
||||
acc[key.toLowerCase()] = aObject[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private parseCurrency({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.CURRENCY_KEYS) {
|
||||
if (item[key]) {
|
||||
return item[key];
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.currency is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseDate({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
let date: string;
|
||||
|
||||
for (const key of ImportTransactionsService.DATE_KEYS) {
|
||||
if (item[key]) {
|
||||
try {
|
||||
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
|
||||
} catch {}
|
||||
|
||||
if (date) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.date is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseFee({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.FEE_KEYS) {
|
||||
if ((item[key] || item[key] === 0) && isNumber(item[key])) {
|
||||
return item[key];
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.fee is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseQuantity({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.QUANTITY_KEYS) {
|
||||
if (item[key] && isNumber(item[key])) {
|
||||
return item[key];
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.quantity is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseSymbol({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.SYMBOL_KEYS) {
|
||||
if (item[key]) {
|
||||
return item[key];
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.symbol is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseType({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.TYPE_KEYS) {
|
||||
if (item[key]) {
|
||||
if (item[key].toLowerCase() === 'buy') {
|
||||
return Type.BUY;
|
||||
} else if (item[key].toLowerCase() === 'sell') {
|
||||
return Type.SELL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw { message: `orders.${index}.type is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseUnitPrice({
|
||||
content,
|
||||
index,
|
||||
item
|
||||
}: {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) {
|
||||
if (item[key] && isNumber(item[key])) {
|
||||
return item[key];
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
message: `orders.${index}.unitPrice is not valid`,
|
||||
orders: content
|
||||
};
|
||||
}
|
||||
|
||||
private postImport(aImportData: { orders: CreateOrderDto[] }) {
|
||||
return this.http.post<void>('/api/import', aImportData);
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { Statistics } from './statistics.interface';
|
||||
import { Subscription } from './subscription.interface';
|
||||
|
||||
@ -11,6 +13,7 @@ export interface InfoItem {
|
||||
type: string;
|
||||
};
|
||||
platforms: { id: string; name: string }[];
|
||||
primaryDataSource: DataSource;
|
||||
statistics: Statistics;
|
||||
stripePublicKey?: string;
|
||||
subscriptions: Subscription[];
|
||||
|
@ -75,6 +75,7 @@ export class PortfolioProportionChartComponent
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
Chart.unregister(ChartDataLabels);
|
||||
this.chart?.destroy();
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.59.0",
|
||||
"version": "1.62.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -73,6 +73,7 @@
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
"@stripe/stripe-js": "1.15.0",
|
||||
"@types/papaparse": "5.2.6",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "2.1.2",
|
||||
"bent": "7.3.12",
|
||||
@ -99,6 +100,7 @@
|
||||
"ngx-markdown": "12.0.1",
|
||||
"ngx-skeleton-loader": "2.9.1",
|
||||
"ngx-stripe": "12.0.2",
|
||||
"papaparse": "5.3.1",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
|
12
yarn.lock
12
yarn.lock
@ -3920,6 +3920,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.1.tgz#fb637071b545834fb12aea94ee309a2ff4cdc0a8"
|
||||
integrity sha512-V25YHbSoKQN35UasHf0EKD9U2vcmexRSp78qa8UglxFH8H3D+adEa9zGZwrqpH4TdvqeMrgMqVqsLB4woAryrQ==
|
||||
|
||||
"@types/papaparse@5.2.6":
|
||||
version "5.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.2.6.tgz#0bba18de4d15eff65883bc7c0794e0134de9e7c7"
|
||||
integrity sha512-xGKSd0UTn58N1h0+zf8mW863Rv8BvXcGibEgKFtBIXZlcDXAmX/T4RdDO2mwmrmOypUDt5vRgo2v32a78JdqUA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/parse-json@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||
@ -13146,6 +13153,11 @@ pako@^1.0.3, pako@~1.0.5:
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||
|
||||
papaparse@5.3.1:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.1.tgz#770b7a9124d821d4b2132132b7bd7dce7194b5b1"
|
||||
integrity sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==
|
||||
|
||||
parallel-transform@^1.1.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"
|
||||
|
Reference in New Issue
Block a user