Feature/extract historical market data editor to reusable component (#4080)
* Extract historical market data editor to reusable component * Update changelog
This commit is contained in:
parent
c85a1be3cf
commit
11d5f36c31
@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added pagination to the users table of the admin control panel
|
- Added pagination to the users table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the historical market data editor to a reusable component
|
||||||
|
|
||||||
## 2.125.0 - 2024-11-30
|
## 2.125.0 - 2024-11-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|
||||||
|
|
||||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
|
||||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [AdminMarketDataDetailComponent],
|
|
||||||
exports: [AdminMarketDataDetailComponent],
|
|
||||||
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
})
|
|
||||||
export class GfAdminMarketDataDetailModule {}
|
|
@ -1,26 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
|
|
||||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [MarketDataDetailDialog],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatDatepickerModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
ReactiveFormsModule
|
|
||||||
],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
})
|
|
||||||
export class GfMarketDataDetailDialogModule {}
|
|
@ -3,5 +3,9 @@
|
|||||||
|
|
||||||
.mat-mdc-dialog-content {
|
.mat-mdc-dialog-content {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
|
|
||||||
|
gf-line-chart {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
|
||||||
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
|
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
|
||||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
|
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
|
||||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AssetProfileIdentifier
|
AssetProfileIdentifier,
|
||||||
|
LineChartItem,
|
||||||
|
User
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
|
||||||
@ -23,7 +25,6 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -31,7 +32,6 @@ import {
|
|||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { parse as csvToJson } from 'papaparse';
|
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
};
|
};
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||||
|
public historicalDataItems: LineChartItem[];
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataItems: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public user: User;
|
||||||
|
|
||||||
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||||
new Date(),
|
new Date(),
|
||||||
@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private snackBar: MatSnackBar
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
|
this.historicalDataItems = undefined;
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketDataBySymbol({
|
.fetchAdminMarketDataBySymbol({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
||||||
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
|
|
||||||
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
||||||
return id === this.assetProfile.id;
|
return id === this.assetProfile.id;
|
||||||
});
|
});
|
||||||
this.marketDataDetails = marketData;
|
|
||||||
|
this.historicalDataItems = marketData.map(({ date, marketPrice }) => {
|
||||||
|
return {
|
||||||
|
date: format(date, DATE_FORMAT),
|
||||||
|
value: marketPrice
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.marketDataItems = marketData;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
|
|
||||||
if (this.assetProfile?.countries?.length > 0) {
|
if (this.assetProfile?.countries?.length > 0) {
|
||||||
@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onImportHistoricalData() {
|
|
||||||
try {
|
|
||||||
const marketData = csvToJson(
|
|
||||||
this.assetProfileForm.controls['historicalData'].controls['csvString']
|
|
||||||
.value,
|
|
||||||
{
|
|
||||||
dynamicTyping: true,
|
|
||||||
header: true,
|
|
||||||
skipEmptyLines: true
|
|
||||||
}
|
|
||||||
).data as UpdateMarketDataDto[];
|
|
||||||
|
|
||||||
this.adminService
|
|
||||||
.postMarketData({
|
|
||||||
dataSource: this.data.dataSource,
|
|
||||||
marketData: {
|
|
||||||
marketData
|
|
||||||
},
|
|
||||||
symbol: this.data.symbol
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
catchError(({ error, message }) => {
|
|
||||||
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
return EMPTY;
|
|
||||||
}),
|
|
||||||
takeUntil(this.unsubscribeSubject)
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.initialize();
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
this.snackBar.open(
|
|
||||||
$localize`Oops! Could not parse historical data.`,
|
|
||||||
undefined,
|
|
||||||
{ duration: 3000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
@ -68,50 +68,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<gf-admin-market-data-detail
|
<gf-line-chart
|
||||||
|
class="mb-4"
|
||||||
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[isAnimated]="true"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[showXAxis]="true"
|
||||||
|
[showYAxis]="true"
|
||||||
|
[symbol]="data.symbol"
|
||||||
|
/>
|
||||||
|
<gf-historical-market-data-editor
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
[currency]="assetProfile?.currency"
|
[currency]="assetProfile?.currency"
|
||||||
[dataSource]="data.dataSource"
|
[dataSource]="data.dataSource"
|
||||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[marketData]="marketDataDetails"
|
[marketData]="marketDataItems"
|
||||||
[symbol]="data.symbol"
|
[symbol]="data.symbol"
|
||||||
|
[user]="user"
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-3" formGroupName="historicalData">
|
|
||||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
|
||||||
<mat-label>
|
|
||||||
<ng-container i18n>Historical Data</ng-container> (CSV)
|
|
||||||
</mat-label>
|
|
||||||
<textarea
|
|
||||||
cdkAutosizeMaxRows="5"
|
|
||||||
cdkTextareaAutosize
|
|
||||||
formControlName="csvString"
|
|
||||||
matInput
|
|
||||||
type="text"
|
|
||||||
(keyup.enter)="$event.stopPropagation()"
|
|
||||||
></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mt-2">
|
|
||||||
<button
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
type="button"
|
|
||||||
[disabled]="
|
|
||||||
!assetProfileForm.controls['historicalData']?.controls['csvString']
|
|
||||||
.touched ||
|
|
||||||
assetProfileForm.controls['historicalData']?.controls['csvString']
|
|
||||||
?.value === ''
|
|
||||||
"
|
|
||||||
(click)="onImportHistoricalData()"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Import</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
|
||||||
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
|
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
|
||||||
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||||
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
|
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
|
||||||
|
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
|
||||||
|
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';
|
||||||
|
|
||||||
@ -24,9 +25,10 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GfAdminMarketDataDetailModule,
|
|
||||||
GfAssetProfileIconComponent,
|
GfAssetProfileIconComponent,
|
||||||
GfCurrencySelectorComponent,
|
GfCurrencySelectorComponent,
|
||||||
|
GfHistoricalMarketDataEditorComponent,
|
||||||
|
GfLineChartComponent,
|
||||||
GfPortfolioProportionChartComponent,
|
GfPortfolioProportionChartComponent,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -1,34 +1,58 @@
|
|||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogModule,
|
||||||
|
MatDialogRef
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'h-100' },
|
|
||||||
selector: 'gf-market-data-detail-dialog',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styleUrls: ['./market-data-detail-dialog.scss'],
|
host: { class: 'h-100' },
|
||||||
templateUrl: 'market-data-detail-dialog.html'
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
selector: 'gf-historical-market-data-editor-dialog',
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
standalone: true,
|
||||||
|
styleUrls: ['./historical-market-data-editor-dialog.scss'],
|
||||||
|
templateUrl: 'historical-market-data-editor-dialog.html'
|
||||||
})
|
})
|
||||||
export class MarketDataDetailDialog implements OnDestroy {
|
export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy {
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
|
@Inject(MAT_DIALOG_DATA)
|
||||||
|
public data: HistoricalMarketDataEditorDialogParams,
|
||||||
private dateAdapter: DateAdapter<any>,
|
private dateAdapter: DateAdapter<any>,
|
||||||
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>,
|
||||||
@Inject(MAT_DATE_LOCALE) private locale: string
|
@Inject(MAT_DATE_LOCALE) private locale: string
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -2,7 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
|||||||
|
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface MarketDataDetailDialogParams {
|
export interface HistoricalMarketDataEditorDialogParams {
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
dateString: string;
|
dateString: string;
|
@ -1,14 +1,4 @@
|
|||||||
<div>
|
<div>
|
||||||
<gf-line-chart
|
|
||||||
class="mb-4"
|
|
||||||
[colorScheme]="user?.settings?.colorScheme"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
|
||||||
[isAnimated]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[showXAxis]="true"
|
|
||||||
[showYAxis]="true"
|
|
||||||
[symbol]="symbol"
|
|
||||||
/>
|
|
||||||
@for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
|
@for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
@ -43,4 +33,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="historicalDataForm"
|
||||||
|
(ngSubmit)="onImportHistoricalData()"
|
||||||
|
>
|
||||||
|
<div class="mt-3" formGroupName="historicalData">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label>
|
||||||
|
<ng-container i18n>Historical Data</ng-container> (CSV)
|
||||||
|
</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMaxRows="5"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
formControlName="csvString"
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-2">
|
||||||
|
<button
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="
|
||||||
|
!historicalDataForm.controls['historicalData']?.controls['csvString']
|
||||||
|
.touched ||
|
||||||
|
historicalDataForm.controls['historicalData']?.controls['csvString']
|
||||||
|
?.value === ''
|
||||||
|
"
|
||||||
|
(click)="onImportHistoricalData()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Import</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
@ -2,10 +2,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|
||||||
gf-line-chart {
|
|
||||||
aspect-ratio: 16/9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
@ -1,4 +1,5 @@
|
|||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getDateFormatString,
|
getDateFormatString,
|
||||||
@ -6,15 +7,22 @@ import {
|
|||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@ -29,55 +37,70 @@ import {
|
|||||||
parseISO
|
parseISO
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, last } from 'lodash';
|
import { first, last } from 'lodash';
|
||||||
|
import ms from 'ms';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { parse as csvToJson } from 'papaparse';
|
||||||
|
import { EMPTY, Subject, takeUntil } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces';
|
import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component';
|
||||||
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
import { HistoricalMarketDataEditorDialogParams } from './historical-market-data-editor-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'gf-admin-market-data-detail',
|
imports: [CommonModule, MatButtonModule, MatInputModule, ReactiveFormsModule],
|
||||||
styleUrls: ['./admin-market-data-detail.component.scss'],
|
selector: 'gf-historical-market-data-editor',
|
||||||
templateUrl: './admin-market-data-detail.component.html'
|
standalone: true,
|
||||||
|
styleUrls: ['./historical-market-data-editor.component.scss'],
|
||||||
|
templateUrl: './historical-market-data-editor.component.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataDetailComponent implements OnChanges {
|
export class GfHistoricalMarketDataEditorComponent
|
||||||
|
implements OnChanges, OnDestroy, OnInit
|
||||||
|
{
|
||||||
@Input() currency: string;
|
@Input() currency: string;
|
||||||
@Input() dataSource: DataSource;
|
@Input() dataSource: DataSource;
|
||||||
@Input() dateOfFirstActivity: string;
|
@Input() dateOfFirstActivity: string;
|
||||||
@Input() locale = getLocale();
|
@Input() locale = getLocale();
|
||||||
@Input() marketData: MarketData[];
|
@Input() marketData: MarketData[];
|
||||||
@Input() symbol: string;
|
@Input() symbol: string;
|
||||||
|
@Input() user: User;
|
||||||
|
|
||||||
@Output() marketDataChanged = new EventEmitter<boolean>();
|
@Output() marketDataChanged = new EventEmitter<boolean>();
|
||||||
|
|
||||||
public days = Array(31);
|
public days = Array(31);
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public historicalDataForm = this.formBuilder.group({
|
||||||
|
historicalData: this.formBuilder.group({
|
||||||
|
csvString: ''
|
||||||
|
})
|
||||||
|
});
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public marketDataByMonth: {
|
public marketDataByMonth: {
|
||||||
[yearMonth: string]: {
|
[yearMonth: string]: {
|
||||||
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
|
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
public user: User;
|
|
||||||
|
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||||
|
new Date(),
|
||||||
|
DATE_FORMAT
|
||||||
|
)};123.45`;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private userService: UserService
|
private formBuilder: FormBuilder,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
) {
|
) {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
this.userService.stateChanged
|
public ngOnInit() {
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
this.initializeHistoricalDataForm();
|
||||||
.subscribe((state) => {
|
|
||||||
if (state?.user) {
|
|
||||||
this.user = state.user;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
@ -177,29 +200,84 @@ export class AdminMarketDataDetailComponent implements OnChanges {
|
|||||||
}) {
|
}) {
|
||||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
const dialogRef = this.dialog.open(
|
||||||
data: {
|
GfHistoricalMarketDataEditorDialogComponent,
|
||||||
marketPrice,
|
{
|
||||||
currency: this.currency,
|
data: {
|
||||||
dataSource: this.dataSource,
|
marketPrice,
|
||||||
dateString: `${yearMonth}-${day}`,
|
currency: this.currency,
|
||||||
symbol: this.symbol,
|
dataSource: this.dataSource,
|
||||||
user: this.user
|
dateString: `${yearMonth}-${day}`,
|
||||||
} as MarketDataDetailDialogParams,
|
symbol: this.symbol,
|
||||||
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
|
user: this.user
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
} as HistoricalMarketDataEditorDialogParams,
|
||||||
});
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
||||||
this.marketDataChanged.next(withRefresh);
|
this.marketDataChanged.emit(withRefresh);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImportHistoricalData() {
|
||||||
|
try {
|
||||||
|
const marketData = csvToJson(
|
||||||
|
this.historicalDataForm.controls['historicalData'].controls['csvString']
|
||||||
|
.value,
|
||||||
|
{
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}
|
||||||
|
).data as UpdateMarketDataDto[];
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.postMarketData({
|
||||||
|
dataSource: this.dataSource,
|
||||||
|
marketData: {
|
||||||
|
marketData
|
||||||
|
},
|
||||||
|
symbol: this.symbol
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
catchError(({ error, message }) => {
|
||||||
|
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
|
||||||
|
duration: ms('3 seconds')
|
||||||
|
});
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initializeHistoricalDataForm();
|
||||||
|
|
||||||
|
this.marketDataChanged.emit(true);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.snackBar.open(
|
||||||
|
$localize`Oops! Could not parse historical data.`,
|
||||||
|
undefined,
|
||||||
|
{ duration: ms('3 seconds') }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initializeHistoricalDataForm() {
|
||||||
|
this.historicalDataForm.setValue({
|
||||||
|
historicalData: {
|
||||||
|
csvString:
|
||||||
|
GfHistoricalMarketDataEditorComponent.HISTORICAL_DATA_TEMPLATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
1
libs/ui/src/lib/historical-market-data-editor/index.ts
Normal file
1
libs/ui/src/lib/historical-market-data-editor/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './historical-market-data-editor.component';
|
Loading…
x
Reference in New Issue
Block a user