Feature/add public portfolio (#426)
* Setup public portfolio page * Update changelog
This commit is contained in:
@@ -52,6 +52,13 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'p',
|
||||
loadChildren: () =>
|
||||
import('./pages/public/public-page.module').then(
|
||||
(m) => m.PublicPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio',
|
||||
loadChildren: () =>
|
||||
|
@@ -9,8 +9,14 @@
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted View
|
||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||
{{ baseUrl }}/p/{{ element.id }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted View
|
||||
</ng-container>
|
||||
</td></ng-container
|
||||
>
|
||||
|
||||
|
@@ -17,6 +17,7 @@ import { Access } from '@ghostfolio/common/interfaces';
|
||||
export class AccessTableComponent implements OnChanges, OnInit {
|
||||
@Input() accesses: Access[];
|
||||
|
||||
public baseUrl = window.location.origin;
|
||||
public dataSource: MatTableDataSource<Access>;
|
||||
public displayedColumns = ['granteeAlias', 'type'];
|
||||
|
||||
|
@@ -18,6 +18,7 @@ export class AuthGuard implements CanActivate {
|
||||
'/about',
|
||||
'/de/blog',
|
||||
'/en/blog',
|
||||
'/p',
|
||||
'/pricing',
|
||||
'/register',
|
||||
'/resources'
|
||||
|
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { PublicPageComponent } from './public-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: ':id', component: PublicPageComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class PublicPageRoutingModule {}
|
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioPosition,
|
||||
PortfolioPublicDetails
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-public-page',
|
||||
styleUrls: ['./public-page.scss'],
|
||||
templateUrl: './public-page.html'
|
||||
})
|
||||
export class PublicPageComponent implements OnInit {
|
||||
public continents: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public deviceType: string;
|
||||
public portfolioPublicDetails: PortfolioPublicDetails;
|
||||
public positions: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name' | 'value'>;
|
||||
};
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public symbols: {
|
||||
[name: string]: { name: string; symbol: string; value: number };
|
||||
};
|
||||
|
||||
private id: string;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private router: Router
|
||||
) {
|
||||
this.activatedRoute.params.subscribe((params) => {
|
||||
this.id = params['id'];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPublic(this.id)
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
catchError((error) => {
|
||||
if (error.status === StatusCodes.NOT_FOUND) {
|
||||
console.error(error);
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe((portfolioPublicDetails) => {
|
||||
this.portfolioPublicDetails = portfolioPublicDetails;
|
||||
|
||||
this.initializeAnalysisData();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public initializeAnalysisData() {
|
||||
this.continents = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.countries = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.positions = {};
|
||||
this.sectors = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.symbols = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
symbol: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
|
||||
for (const [symbol, position] of Object.entries(
|
||||
this.portfolioPublicDetails.holdings
|
||||
)) {
|
||||
const value = position.allocationCurrent;
|
||||
|
||||
this.positions[symbol] = {
|
||||
value,
|
||||
name: position.name
|
||||
};
|
||||
|
||||
if (position.countries.length > 0) {
|
||||
for (const country of position.countries) {
|
||||
const { code, continent, name, weight } = country;
|
||||
|
||||
if (this.continents[continent]?.value) {
|
||||
this.continents[continent].value += weight * position.value;
|
||||
} else {
|
||||
this.continents[continent] = {
|
||||
name: continent,
|
||||
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||
};
|
||||
}
|
||||
|
||||
if (this.countries[code]?.value) {
|
||||
this.countries[code].value += weight * position.value;
|
||||
} else {
|
||||
this.countries[code] = {
|
||||
name,
|
||||
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.continents[UNKNOWN_KEY].value +=
|
||||
this.portfolioPublicDetails.holdings[symbol].value;
|
||||
|
||||
this.countries[UNKNOWN_KEY].value +=
|
||||
this.portfolioPublicDetails.holdings[symbol].value;
|
||||
}
|
||||
|
||||
if (position.sectors.length > 0) {
|
||||
for (const sector of position.sectors) {
|
||||
const { name, weight } = sector;
|
||||
|
||||
if (this.sectors[name]?.value) {
|
||||
this.sectors[name].value += weight * position.value;
|
||||
} else {
|
||||
this.sectors[name] = {
|
||||
name,
|
||||
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.sectors[UNKNOWN_KEY].value +=
|
||||
this.portfolioPublicDetails.holdings[symbol].value;
|
||||
}
|
||||
|
||||
this.symbols[symbol] = {
|
||||
symbol,
|
||||
name: position.name,
|
||||
value: position.value
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
38
apps/client/src/app/pages/public/public-page.html
Normal file
38
apps/client/src/app/pages/public/public-page.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proportion-charts row">
|
||||
<div class="col-md-12 allocations-by-symbol">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
class="mx-auto"
|
||||
[isInPercent]="true"
|
||||
[keys]="['symbol']"
|
||||
[positions]="symbols"
|
||||
[showLabels]="deviceType !== 'mobile'"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
<p class="lead mb-3 text-center" i18n>
|
||||
Ghostfolio empowers you to keep track of your wealth.
|
||||
</p>
|
||||
<div class="py-2 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
|
||||
import { PublicPageRoutingModule } from './public-page-routing.module';
|
||||
import { PublicPageComponent } from './public-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [PublicPageComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
PublicPageRoutingModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class PublicPageModule {}
|
12
apps/client/src/app/pages/public/public-page.scss
Normal file
12
apps/client/src/app/pages/public/public-page.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
gf-portfolio-proportion-chart {
|
||||
max-width: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
@@ -22,6 +22,7 @@ import {
|
||||
InfoItem,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
User
|
||||
@@ -164,6 +165,12 @@ export class DataService {
|
||||
});
|
||||
}
|
||||
|
||||
public fetchPortfolioPublic(aId: string) {
|
||||
return this.http.get<PortfolioPublicDetails>(
|
||||
`/api/portfolio/public/${aId}`
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioReport() {
|
||||
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
||||
}
|
||||
|
Reference in New Issue
Block a user