diff --git a/CHANGELOG.md b/CHANGELOG.md index 0327fafe0..d0d234b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Moved the positions table to a dedicated section (_Holdings_) - Changed the data gathering by symbol endpoint to delete data first ## 1.164.0 - 23.06.2022 diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 08e285c89..50cbe072d 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -127,6 +127,13 @@ const routes: Routes = [ (m) => m.FirePageModule ) }, + { + path: 'portfolio/holdings', + loadChildren: () => + import('./pages/portfolio/holdings/holdings-page.module').then( + (m) => m.HoldingsPageModule + ) + }, { path: 'portfolio/report', loadChildren: () => diff --git a/apps/client/src/app/components/positions-table/positions-table.component.ts b/apps/client/src/app/components/positions-table/positions-table.component.ts index 22e9e282a..3cd313698 100644 --- a/apps/client/src/app/components/positions-table/positions-table.component.ts +++ b/apps/client/src/app/components/positions-table/positions-table.component.ts @@ -29,6 +29,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { @Input() deviceType: string; @Input() hasPermissionToShowValues = true; @Input() locale: string; + @Input() pageSize = Number.MAX_SAFE_INTEGER; @Input() positions: PortfolioPosition[]; @Output() transactionDeleted = new EventEmitter(); @@ -45,7 +46,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { ASSET_SUB_CLASS_EMERGENCY_FUND ]; public isLoading = true; - public pageSize = 7; public routeQueryParams: Subscription; private unsubscribeSubject = new Subject(); diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index b8cc651f6..cec50988f 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -68,7 +68,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { | 'value' >; }; - public positionsArray: PortfolioPosition[]; public routeQueryParams: Subscription; public sectors: { [name: string]: { name: string; value: number }; @@ -229,7 +228,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { } }; this.positions = {}; - this.positionsArray = []; this.sectors = { [UNKNOWN_KEY]: { name: UNKNOWN_KEY, @@ -285,7 +283,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { exchange: position.exchange, name: position.name }; - this.positionsArray.push(position); if (position.assetClass !== AssetClass.CASH) { // Prepare analysis data by continents, countries and sectors except for cash diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index e4cc80908..416af40c0 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -262,14 +262,4 @@ -
-
- -
-
diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts index 5b5d80216..65594048b 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts @@ -1,7 +1,6 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; -import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; @@ -14,20 +13,17 @@ import { AllocationsPageComponent } from './allocations-page.component'; @NgModule({ declarations: [AllocationsPageComponent], - exports: [], imports: [ AllocationsPageRoutingModule, CommonModule, GfActivitiesFilterModule, GfPortfolioProportionChartModule, - GfPositionsTableModule, GfPremiumIndicatorModule, GfToggleModule, GfWorldMapChartModule, GfValueModule, MatCardModule ], - providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AllocationsPageModule {} diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.scss b/apps/client/src/app/pages/portfolio/allocations/allocations-page.scss index 467e756d1..0a12304c1 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.scss +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.scss @@ -27,14 +27,5 @@ font-size: 90%; } } - - a { - color: rgba(var(--palette-primary-500), 1); - font-weight: 500; - - &:hover { - color: rgba(var(--palette-primary-300), 1); - } - } } } diff --git a/apps/client/src/app/pages/portfolio/holdings/holdings-page-routing.module.ts b/apps/client/src/app/pages/portfolio/holdings/holdings-page-routing.module.ts new file mode 100644 index 000000000..a1ede073e --- /dev/null +++ b/apps/client/src/app/pages/portfolio/holdings/holdings-page-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; + +import { HoldingsPageComponent } from './holdings-page.component'; + +const routes: Routes = [ + { path: '', component: HoldingsPageComponent, canActivate: [AuthGuard] } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class HoldingsPageRoutingModule {} diff --git a/apps/client/src/app/pages/portfolio/holdings/holdings-page.component.ts b/apps/client/src/app/pages/portfolio/holdings/holdings-page.component.ts new file mode 100644 index 000000000..2500d3fa1 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/holdings/holdings-page.component.ts @@ -0,0 +1,220 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces'; +import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { + Filter, + PortfolioDetails, + PortfolioPosition, + User +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { AssetClass, DataSource } from '@prisma/client'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; + +@Component({ + host: { class: 'page' }, + selector: 'gf-holdings-page', + styleUrls: ['./holdings-page.scss'], + templateUrl: './holdings-page.html' +}) +export class HoldingsPageComponent implements OnDestroy, OnInit { + public activeFilters: Filter[] = []; + public allFilters: Filter[]; + public deviceType: string; + public filters$ = new Subject(); + public hasImpersonationId: boolean; + public hasPermissionToCreateOrder: boolean; + public isLoading = false; + public placeholder = ''; + public portfolioDetails: PortfolioDetails; + public positionsArray: PortfolioPosition[]; + public routeQueryParams: Subscription; + public user: User; + + private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...'; + private unsubscribeSubject = new Subject(); + + /** + * @constructor + */ + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private impersonationStorageService: ImpersonationStorageService, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + this.routeQueryParams = route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if ( + params['dataSource'] && + params['positionDetailDialog'] && + params['symbol'] + ) { + this.openPositionDialog({ + dataSource: params['dataSource'], + symbol: params['symbol'] + }); + } + }); + } + + /** + * Initializes the controller + */ + public ngOnInit() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.impersonationStorageService + .onChangeHasImpersonation() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((aId) => { + this.hasImpersonationId = !!aId; + }); + + this.filters$ + .pipe( + distinctUntilChanged(), + switchMap((filters) => { + this.isLoading = true; + this.activeFilters = filters; + this.placeholder = + this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : ''; + + return this.dataService.fetchPortfolioDetails({ + filters: this.activeFilters + }); + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe((portfolioDetails) => { + this.portfolioDetails = portfolioDetails; + + this.initializeAnalysisData(); + + this.isLoading = false; + + this.changeDetectorRef.markForCheck(); + }); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToCreateOrder = hasPermission( + this.user.permissions, + permissions.createOrder + ); + + const accountFilters: Filter[] = this.user.accounts + .filter(({ accountType }) => { + return accountType === 'SECURITIES'; + }) + .map(({ id, name }) => { + return { + id, + label: name, + type: 'ACCOUNT' + }; + }); + + const assetClassFilters: Filter[] = []; + for (const assetClass of Object.keys(AssetClass)) { + assetClassFilters.push({ + id: assetClass, + label: assetClass, + type: 'ASSET_CLASS' + }); + } + + const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => { + return { + id, + label: name, + type: 'TAG' + }; + }); + + this.allFilters = [ + ...accountFilters, + ...assetClassFilters, + ...tagFilters + ]; + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public initialize() { + this.positionsArray = []; + } + + public initializeAnalysisData() { + this.initialize(); + + for (const [symbol, position] of Object.entries( + this.portfolioDetails.holdings + )) { + this.positionsArray.push(position); + } + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private openPositionDialog({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }) { + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + const dialogRef = this.dialog.open(PositionDetailDialog, { + autoFocus: false, + data: { + dataSource, + symbol, + baseCurrency: this.user?.settings?.baseCurrency, + deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId, + hasPermissionToReportDataGlitch: hasPermission( + this.user?.permissions, + permissions.reportDataGlitch + ), + locale: this.user?.settings?.locale + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.router.navigate(['.'], { relativeTo: this.route }); + }); + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/holdings/holdings-page.html b/apps/client/src/app/pages/portfolio/holdings/holdings-page.html new file mode 100644 index 000000000..955ac7a12 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/holdings/holdings-page.html @@ -0,0 +1,32 @@ +
+
+
+

Holdings

+ +
+
+
+
+ + +
+
+
diff --git a/apps/client/src/app/pages/portfolio/holdings/holdings-page.module.ts b/apps/client/src/app/pages/portfolio/holdings/holdings-page.module.ts new file mode 100644 index 000000000..88a6ded0f --- /dev/null +++ b/apps/client/src/app/pages/portfolio/holdings/holdings-page.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; +import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; + +import { HoldingsPageRoutingModule } from './holdings-page-routing.module'; +import { HoldingsPageComponent } from './holdings-page.component'; + +@NgModule({ + declarations: [HoldingsPageComponent], + imports: [ + CommonModule, + GfActivitiesFilterModule, + GfPositionsTableModule, + HoldingsPageRoutingModule, + MatButtonModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class HoldingsPageModule {} diff --git a/apps/client/src/app/pages/portfolio/holdings/holdings-page.scss b/apps/client/src/app/pages/portfolio/holdings/holdings-page.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/holdings/holdings-page.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.html b/apps/client/src/app/pages/portfolio/portfolio-page.html index 682504df5..73f1f2f52 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.html +++ b/apps/client/src/app/pages/portfolio/portfolio-page.html @@ -1,10 +1,28 @@

Portfolio

+
+ +

Holdings

+
+ Get an overview of your current holdings. +
+ +
+

Activities

-
+
Manage your activities: stocks, ETFs, cryptocurrencies, dividend, and valuables.
@@ -29,7 +47,7 @@ class="ml-1" > -
+
Check the allocations of your portfolio by account, asset class, currency, sector and region.
@@ -54,7 +72,7 @@ class="ml-1" > -
+
Ghostfolio Analysis visualizes your portfolio and shows your top and bottom performers.
@@ -79,7 +97,7 @@ class="ml-1" > -
+
Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio.
@@ -100,7 +118,7 @@ class="ml-1" > -
+
Ghostfolio FIRE calculates metrics for the Financial Independence, Retire Early lifestyle.
diff --git a/apps/client/src/app/pages/public/public-page.html b/apps/client/src/app/pages/public/public-page.html index 7c9f98e89..5afb4b8c1 100644 --- a/apps/client/src/app/pages/public/public-page.html +++ b/apps/client/src/app/pages/public/public-page.html @@ -112,6 +112,7 @@