Feature/add public portfolio (#426)
* Setup public portfolio page * Update changelog
This commit is contained in:
parent
43104f81d0
commit
6dea9093ba
@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added a public page to share your portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.62.0 - 17.10.2021
|
||||
|
||||
### Added
|
||||
|
@ -24,8 +24,18 @@ export class AccessController {
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return {
|
||||
granteeAlias: access.GranteeUser.alias
|
||||
granteeAlias: access.GranteeUser?.alias,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
granteeAlias: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AccessController],
|
||||
exports: [AccessService],
|
||||
imports: [],
|
||||
providers: [AccessService, PrismaService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -7,6 +7,17 @@ import { Prisma } from '@prisma/client';
|
||||
export class AccessService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async access(
|
||||
accessWhereInput: Prisma.AccessWhereInput
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accesses(params: {
|
||||
include?: Prisma.AccessInclude;
|
||||
skip?: number;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
@ -5,9 +6,11 @@ import {
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -39,6 +42,7 @@ import { PortfolioService } from './portfolio.service';
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -145,7 +149,11 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(impersonationId, range);
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range
|
||||
);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
@ -252,6 +260,59 @@ export class PortfolioController {
|
||||
return <any>res.json(result);
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
|
||||
if (!access) {
|
||||
res.status(StatusCodes.NOT_FOUND);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId
|
||||
);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
holdings: {}
|
||||
};
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter((holding) => {
|
||||
return holding.assetClass === 'EQUITY';
|
||||
})
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||
countries: [],
|
||||
name: portfolioPosition.name,
|
||||
sectors: [],
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(portfolioPublicDetails);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSummary(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,3 +1,5 @@
|
||||
// TODO ///////////
|
||||
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
@ -21,7 +23,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
||||
import {
|
||||
UNKNOWN_KEY,
|
||||
baseCurrency,
|
||||
ghostfolioCashSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
@ -78,7 +84,7 @@ export class PortfolioService {
|
||||
public async getInvestments(
|
||||
aImpersonationId: string
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -106,7 +112,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -148,11 +154,12 @@ export class PortfolioService {
|
||||
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aUserId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
@ -265,7 +272,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
@ -484,7 +491,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -555,7 +562,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -628,8 +635,8 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const userId = await this.getUserId(impersonationId);
|
||||
const baseCurrency = this.request.user.Settings.currency;
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
@ -643,7 +650,7 @@ export class PortfolioService {
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
currency
|
||||
);
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
@ -659,7 +666,7 @@ export class PortfolioService {
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
baseCurrency,
|
||||
currency,
|
||||
userId
|
||||
);
|
||||
return {
|
||||
@ -679,7 +686,7 @@ export class PortfolioService {
|
||||
accounts
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -700,7 +707,7 @@ export class PortfolioService {
|
||||
currentPositions
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -710,7 +717,7 @@ export class PortfolioService {
|
||||
this.getFees(orders)
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
)
|
||||
}
|
||||
};
|
||||
@ -718,7 +725,7 @@ export class PortfolioService {
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
@ -820,7 +827,7 @@ export class PortfolioService {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
@ -920,14 +927,14 @@ export class PortfolioService {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string) {
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
aUserId
|
||||
);
|
||||
|
||||
return impersonationUserId || this.request.user.id;
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
|
@ -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>
|
||||
<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');
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
export interface Access {
|
||||
granteeAlias: string;
|
||||
id: string;
|
||||
type: 'PUBLIC' | 'RESTRICTED_VIEW';
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { PortfolioItem } from './portfolio-item.interface';
|
||||
import { PortfolioOverview } from './portfolio-overview.interface';
|
||||
import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||
import { PortfolioPosition } from './portfolio-position.interface';
|
||||
import { PortfolioPublicDetails } from './portfolio-public-details.interface';
|
||||
import { PortfolioReportRule } from './portfolio-report-rule.interface';
|
||||
import { PortfolioReport } from './portfolio-report.interface';
|
||||
import { PortfolioSummary } from './portfolio-summary.interface';
|
||||
@ -26,6 +27,7 @@ export {
|
||||
PortfolioOverview,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioReportRule,
|
||||
PortfolioSummary,
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioPublicDetails {
|
||||
holdings: {
|
||||
[symbol: string]: Pick<
|
||||
PortfolioPosition,
|
||||
'allocationCurrent' | 'countries' | 'name' | 'sectors' | 'value'
|
||||
>;
|
||||
};
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Access" ALTER COLUMN "granteeUserId" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ALTER COLUMN "currency" DROP NOT NULL;
|
@ -14,8 +14,8 @@ generator client {
|
||||
|
||||
model Access {
|
||||
createdAt DateTime @default(now())
|
||||
GranteeUser User @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
||||
granteeUserId String
|
||||
GranteeUser User? @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
||||
granteeUserId String?
|
||||
id String @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], name: "accessGive", references: [id])
|
||||
|
Loading…
x
Reference in New Issue
Block a user