mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Move holdings table to holdings tab of home page * Deprecate api/v1/portfolio/positions endpoint * Update changelogpull/3370/head
committed by
GitHub
32 changed files with 108 additions and 649 deletions
@ -1,27 +1,38 @@ |
|||
<div class="container justify-content-center p-3"> |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> |
|||
<mat-card appearance="outlined"> |
|||
<mat-card-content class="p-0"> |
|||
<gf-positions |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="positions" |
|||
[range]="user?.settings?.dateRange" |
|||
/> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<div *ngIf="hasPermissionToCreateOrder" class="text-center"> |
|||
<a |
|||
class="mt-3" |
|||
i18n |
|||
mat-stroked-button |
|||
[routerLink]="['/portfolio', 'activities']" |
|||
>Manage Activities</a |
|||
> |
|||
<div class="col"> |
|||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-lg"> |
|||
<div class="d-flex justify-content-end"> |
|||
<gf-toggle |
|||
class="d-none d-lg-block" |
|||
[defaultValue]="holdingType" |
|||
[isLoading]="false" |
|||
[options]="holdingTypeOptions" |
|||
(change)="onChangeHoldingType($event.value)" |
|||
/> |
|||
</div> |
|||
<gf-holdings-table |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" |
|||
[holdings]="holdings" |
|||
[locale]="user?.settings?.locale" |
|||
/> |
|||
@if (hasPermissionToCreateOrder && holdings?.length > 0) { |
|||
<div class="text-center"> |
|||
<a |
|||
class="mt-3" |
|||
i18n |
|||
mat-stroked-button |
|||
[routerLink]="['/portfolio', 'activities']" |
|||
>Manage Activities</a |
|||
> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
@ -1,72 +0,0 @@ |
|||
<div class="container p-0"> |
|||
<div class="flex-nowrap no-gutters row"> |
|||
<a |
|||
class="d-flex p-3 w-100" |
|||
[ngClass]="{ 'cursor-default': isLoading }" |
|||
[queryParams]="{ |
|||
dataSource: position?.dataSource, |
|||
positionDetailDialog: true, |
|||
symbol: position?.symbol |
|||
}" |
|||
[routerLink]="[]" |
|||
> |
|||
<div class="d-flex mr-2"> |
|||
<gf-trend-indicator |
|||
class="d-flex" |
|||
size="large" |
|||
[isLoading]="isLoading" |
|||
[marketState]="position?.marketState" |
|||
[range]="range" |
|||
[value]="position?.netPerformancePercentageWithCurrencyEffect" |
|||
/> |
|||
</div> |
|||
<div *ngIf="isLoading" class="flex-grow-1"> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
class="mb-1" |
|||
[theme]="{ |
|||
height: '1.2rem', |
|||
width: '12rem' |
|||
}" |
|||
/> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '8rem' |
|||
}" |
|||
/> |
|||
</div> |
|||
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate"> |
|||
<div class="h6 m-0 text-truncate">{{ position?.name }}</div> |
|||
<div class="d-flex"> |
|||
<small class="text-muted">{{ position?.symbol | gfSymbol }}</small> |
|||
</div> |
|||
<div class="d-flex mt-1"> |
|||
<gf-value |
|||
class="mr-3" |
|||
[colorizeSign]="true" |
|||
[isCurrency]="true" |
|||
[locale]="locale" |
|||
[unit]="baseCurrency" |
|||
[value]="position?.netPerformanceWithCurrencyEffect" |
|||
/> |
|||
<gf-value |
|||
[colorizeSign]="true" |
|||
[isPercent]="true" |
|||
[locale]="locale" |
|||
[value]="position?.netPerformancePercentageWithCurrencyEffect" |
|||
/> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex"> |
|||
<ion-icon |
|||
*ngIf="!isLoading" |
|||
class="chevron text-muted" |
|||
name="chevron-forward-outline" |
|||
size="small" |
|||
/> |
|||
</div> |
|||
</a> |
|||
</div> |
|||
</div> |
@ -1,13 +0,0 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
.container { |
|||
gf-trend-indicator { |
|||
padding-top: 0.15rem; |
|||
} |
|||
|
|||
.chevron { |
|||
opacity: 0.33; |
|||
} |
|||
} |
|||
} |
@ -1,40 +0,0 @@ |
|||
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
|||
import { getLocale } from '@ghostfolio/common/helper'; |
|||
import { Position } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
Input, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { Subject } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-position', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
templateUrl: './position.component.html', |
|||
styleUrls: ['./position.component.scss'] |
|||
}) |
|||
export class PositionComponent implements OnDestroy, OnInit { |
|||
@Input() baseCurrency: string; |
|||
@Input() deviceType: string; |
|||
@Input() isLoading: boolean; |
|||
@Input() locale = getLocale(); |
|||
@Input() position: Position; |
|||
@Input() range: string; |
|||
|
|||
public unknownKey = UNKNOWN_KEY; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor() {} |
|||
|
|||
public ngOnInit() {} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
} |
@ -1,29 +0,0 @@ |
|||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; |
|||
import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator'; |
|||
import { GfValueComponent } from '@ghostfolio/ui/value'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatDialogModule } from '@angular/material/dialog'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|||
|
|||
import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module'; |
|||
import { PositionComponent } from './position.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [PositionComponent], |
|||
exports: [PositionComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfPositionDetailDialogModule, |
|||
GfSymbolModule, |
|||
GfTrendIndicatorComponent, |
|||
GfValueComponent, |
|||
MatDialogModule, |
|||
NgxSkeletonLoaderModule, |
|||
RouterModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class GfPositionModule {} |
@ -1,35 +0,0 @@ |
|||
<div class="container p-0"> |
|||
<div class="row no-gutters"> |
|||
<div class="col"> |
|||
<ng-container *ngIf="positions === undefined"> |
|||
<gf-position [isLoading]="true" /> |
|||
</ng-container> |
|||
<ng-container *ngIf="positions !== undefined"> |
|||
<ng-container *ngIf="hasPositions"> |
|||
<gf-position |
|||
*ngFor="let position of positionsWithPriority" |
|||
[baseCurrency]="baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[locale]="locale" |
|||
[position]="position" |
|||
[range]="range" |
|||
/> |
|||
<gf-position |
|||
*ngFor="let position of positionsRest" |
|||
[baseCurrency]="baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[locale]="locale" |
|||
[position]="position" |
|||
[range]="range" |
|||
/> |
|||
</ng-container> |
|||
<div |
|||
*ngIf="hasPermissionToCreateOrder && !hasPositions" |
|||
class="p-3 text-center" |
|||
> |
|||
<gf-no-transactions-info-indicator [hasBorder]="false" /> |
|||
</div> |
|||
</ng-container> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -1,17 +0,0 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
gf-position { |
|||
&:nth-child(even) { |
|||
background-color: rgba(0, 0, 0, var(--gf-theme-alpha-hover)); |
|||
} |
|||
} |
|||
} |
|||
|
|||
:host-context(.is-dark-theme) { |
|||
gf-position { |
|||
&:nth-child(even) { |
|||
background-color: rgba(255, 255, 255, var(--gf-theme-alpha-hover)); |
|||
} |
|||
} |
|||
} |
@ -1,70 +0,0 @@ |
|||
import { getLocale } from '@ghostfolio/common/helper'; |
|||
import { Position } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
Input, |
|||
OnChanges, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-positions', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
templateUrl: './positions.component.html', |
|||
styleUrls: ['./positions.component.scss'] |
|||
}) |
|||
export class PositionsComponent implements OnChanges, OnInit { |
|||
@Input() baseCurrency: string; |
|||
@Input() deviceType: string; |
|||
@Input() hasPermissionToCreateOrder: boolean; |
|||
@Input() locale = getLocale(); |
|||
@Input() positions: Position[]; |
|||
@Input() range: string; |
|||
|
|||
public hasPositions: boolean; |
|||
public positionsRest: Position[] = []; |
|||
public positionsWithPriority: Position[] = []; |
|||
|
|||
public constructor() {} |
|||
|
|||
public ngOnInit() {} |
|||
|
|||
public ngOnChanges() { |
|||
if (this.positions) { |
|||
this.hasPositions = this.positions.length > 0; |
|||
|
|||
if (!this.hasPositions) { |
|||
return; |
|||
} |
|||
|
|||
this.positionsRest = []; |
|||
this.positionsWithPriority = []; |
|||
|
|||
for (const portfolioPosition of this.positions) { |
|||
if (portfolioPosition.marketState === 'open' || this.range !== '1d') { |
|||
// Only show positions where the market is open in today's view
|
|||
this.positionsWithPriority.push(portfolioPosition); |
|||
} else { |
|||
this.positionsRest.push(portfolioPosition); |
|||
} |
|||
} |
|||
|
|||
this.positionsRest.sort((a, b) => |
|||
(a.name || a.symbol)?.toLowerCase() > |
|||
(b.name || b.symbol)?.toLowerCase() |
|||
? 1 |
|||
: -1 |
|||
); |
|||
this.positionsWithPriority.sort((a, b) => |
|||
(a.name || a.symbol)?.toLowerCase() > |
|||
(b.name || b.symbol)?.toLowerCase() |
|||
? 1 |
|||
: -1 |
|||
); |
|||
} else { |
|||
this.hasPositions = false; |
|||
} |
|||
} |
|||
} |
@ -1,21 +0,0 @@ |
|||
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
|
|||
import { GfPositionModule } from '../position/position.module'; |
|||
import { PositionsComponent } from './positions.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [PositionsComponent], |
|||
exports: [PositionsComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfNoTransactionsInfoComponent, |
|||
GfPositionModule, |
|||
MatButtonModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class GfPositionsModule {} |
@ -1,21 +0,0 @@ |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
|
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule, Routes } from '@angular/router'; |
|||
|
|||
import { HoldingsPageComponent } from './holdings-page.component'; |
|||
|
|||
const routes: Routes = [ |
|||
{ |
|||
canActivate: [AuthGuard], |
|||
component: HoldingsPageComponent, |
|||
path: '', |
|||
title: $localize`Holdings` |
|||
} |
|||
]; |
|||
|
|||
@NgModule({ |
|||
imports: [RouterModule.forChild(routes)], |
|||
exports: [RouterModule] |
|||
}) |
|||
export class HoldingsPageRoutingModule {} |
@ -1,171 +0,0 @@ |
|||
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 { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { HoldingType, ToggleOption } from '@ghostfolio/common/types'; |
|||
|
|||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-holdings-page', |
|||
styleUrls: ['./holdings-page.scss'], |
|||
templateUrl: './holdings-page.html' |
|||
}) |
|||
export class HoldingsPageComponent implements OnDestroy, OnInit { |
|||
public deviceType: string; |
|||
public hasImpersonationId: boolean; |
|||
public hasPermissionToCreateOrder: boolean; |
|||
public holdings: PortfolioPosition[]; |
|||
public holdingType: HoldingType = 'ACTIVE'; |
|||
public holdingTypeOptions: ToggleOption[] = [ |
|||
{ label: $localize`Active`, value: 'ACTIVE' }, |
|||
{ label: $localize`Closed`, value: 'CLOSED' } |
|||
]; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
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 |
|||
) { |
|||
route.queryParams |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((params) => { |
|||
if ( |
|||
params['dataSource'] && |
|||
params['positionDetailDialog'] && |
|||
params['symbol'] |
|||
) { |
|||
this.openPositionDialog({ |
|||
dataSource: params['dataSource'], |
|||
symbol: params['symbol'] |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.impersonationStorageService |
|||
.onChangeHasImpersonation() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((impersonationId) => { |
|||
this.hasImpersonationId = !!impersonationId; |
|||
}); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.hasPermissionToCreateOrder = hasPermission( |
|||
this.user.permissions, |
|||
permissions.createOrder |
|||
); |
|||
|
|||
this.holdings = undefined; |
|||
|
|||
this.fetchHoldings() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(({ holdings }) => { |
|||
this.holdings = holdings; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onChangeHoldingType(aHoldingType: HoldingType) { |
|||
this.holdingType = aHoldingType; |
|||
|
|||
this.holdings = undefined; |
|||
|
|||
this.fetchHoldings() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(({ holdings }) => { |
|||
this.holdings = holdings; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private fetchHoldings() { |
|||
const filters = this.userService.getFilters(); |
|||
|
|||
if (this.holdingType === 'CLOSED') { |
|||
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' }); |
|||
} |
|||
|
|||
return this.dataService.fetchPortfolioHoldings({ |
|||
filters, |
|||
range: this.user?.settings?.dateRange |
|||
}); |
|||
} |
|||
|
|||
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: <PositionDetailDialogParams>{ |
|||
dataSource, |
|||
symbol, |
|||
baseCurrency: this.user?.settings?.baseCurrency, |
|||
colorScheme: this.user?.settings?.colorScheme, |
|||
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 }); |
|||
}); |
|||
}); |
|||
} |
|||
} |
@ -1,38 +0,0 @@ |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-lg"> |
|||
<div class="d-flex justify-content-end"> |
|||
<gf-toggle |
|||
class="d-none d-lg-block" |
|||
[defaultValue]="holdingType" |
|||
[isLoading]="false" |
|||
[options]="holdingTypeOptions" |
|||
(change)="onChangeHoldingType($event.value)" |
|||
/> |
|||
</div> |
|||
<gf-holdings-table |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" |
|||
[holdings]="holdings" |
|||
[locale]="user?.settings?.locale" |
|||
/> |
|||
@if (hasPermissionToCreateOrder && holdings?.length > 0) { |
|||
<div class="text-center"> |
|||
<a |
|||
class="mt-3" |
|||
i18n |
|||
mat-stroked-button |
|||
[routerLink]="['/portfolio', 'activities']" |
|||
>Manage Activities</a |
|||
> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
@ -1,22 +0,0 @@ |
|||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; |
|||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
|
|||
import { HoldingsPageRoutingModule } from './holdings-page-routing.module'; |
|||
import { HoldingsPageComponent } from './holdings-page.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [HoldingsPageComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfHoldingsTableComponent, |
|||
GfToggleModule, |
|||
HoldingsPageRoutingModule, |
|||
MatButtonModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class HoldingsPageModule {} |
@ -1,3 +0,0 @@ |
|||
:host { |
|||
display: block; |
|||
} |
Loading…
Reference in new issue