Compare commits

...

8 Commits

Author SHA1 Message Date
0873f539c5 Release 1.98.0 (#597) 2021-12-29 18:40:18 +01:00
6dcd801d05 Feature/extend statistics section with users in slack community (#596)
* Extend statistics with users in Slack community

* Update changelog
2021-12-29 18:38:55 +01:00
77065dac50 Feature/add date range selector to holdings tab (#595)
* Add date range selector to holdings tab

* Update changelog
2021-12-29 18:14:24 +01:00
438484879d Bugfix/fix creation of historical data (#594)
* Fix creation of historical data (upsert instead of update)

* Update changelog
2021-12-29 17:03:37 +01:00
e37a650c70 Bugfix/fix scrolling issue in position detail dialog on mobile (#593)
* Fix scrolling in position detail dialog on mobile

* Update changelog
2021-12-29 10:51:11 +01:00
6e8c90b3fc Release 1.97.0 (#592) 2021-12-28 21:40:10 +01:00
9e1a7fc981 Feature/dividend (#547)
* Add dividend to order type

* Support dividend in transactions table

* Support dividend in transaction dialog

* Extend import file with dividend

* Add dividend to portfolio summary

* Update changelog

Co-authored-by: Fly Man <fly.man.opensim@gmail.com>
2021-12-28 21:12:12 +01:00
ff638adf03 Feature/add transactions to position detail dialog (#591)
* Add transactions to position detail dialog

* Update changelog
2021-12-28 17:45:04 +01:00
43 changed files with 429 additions and 247 deletions

View File

@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.98.0 - 29.12.2021
### Added
- Added the date range component to the holdings tab
### Changed
- Extended the statistics section on the about page (users in Slack community)
### Fixed
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
- Fixed the scrolling issue in the position detail dialog on mobile
## 1.97.0 - 28.12.2021
### Added
- Added the transactions to the position detail dialog
- Added support for dividend
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.96.0 - 27.12.2021
### Changed

View File

@ -215,7 +215,7 @@ export class AdminController {
const date = new Date(dateString);
return this.marketDataService.updateMarketData({
data,
data: { ...data, dataSource },
where: {
date_symbol: {
date,

View File

@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
@ -187,6 +188,12 @@ export class InfoService {
});
}
private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: InfoService.DEMO_USER_ID
@ -218,19 +225,19 @@ export class InfoService {
} catch {}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
statistics = {
activeUsers1d,
activeUsers7d,
activeUsers30d,
gitHubContributors,
gitHubStargazers,
newUsers30d
newUsers30d,
slackCommunityUsers
};
await this.redisCacheService.set(

View File

@ -66,28 +66,21 @@ export class OrderController {
this.request.user.id
);
let orders = await this.orderService.orders({
include: {
Account: {
include: {
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
let orders = await this.orderService.getOrders({
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
orders = nullifyValuesInObjects(orders, [
'fee',
'quantity',
'unitPrice',
'value'
]);
}
return orders;

View File

@ -3,7 +3,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
@Injectable()
@ -82,11 +83,13 @@ export class OrderService {
});
}
public getOrders({
public async getOrders({
includeDrafts = false,
types,
userId
}: {
includeDrafts?: boolean;
types?: TypeOfOrder[];
userId: string;
}) {
const where: Prisma.OrderWhereInput = { userId };
@ -95,15 +98,39 @@ export class OrderService {
where.isDraft = false;
}
return this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
if (types) {
where.OR = types.map((type) => {
return {
type: {
equals: type
}
};
});
}
return (
await this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
include: {
Platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
})
).map((order) => {
return {
...order,
value: new Big(order.quantity)
.mul(order.unitPrice)
.plus(order.fee)
.toNumber()
};
});
}

View File

@ -1,3 +1,4 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail {
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
name: string;
netPerformance: number;
netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number;
symbol: string;
transactionCount: number;

View File

@ -330,6 +330,7 @@ export class PortfolioController {
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'fees',
'netWorth',
'totalBuy',
@ -360,6 +361,7 @@ export class PortfolioController {
'grossPerformance',
'investment',
'netPerformance',
'orders',
'quantity',
'value'
]);

View File

@ -388,6 +388,7 @@ export class PortfolioService {
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
orders: [],
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined,
@ -400,17 +401,21 @@ export class PortfolioService {
const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -521,6 +526,7 @@ export class PortfolioService {
minPrice,
name,
netPerformance,
orders,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
@ -578,6 +584,7 @@ export class PortfolioService {
maxPrice,
minPrice,
name,
orders,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
@ -726,22 +733,6 @@ export class PortfolioService {
};
}
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
@ -822,7 +813,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees(orders)
this.getFees(orders).toNumber()
)
],
{ baseCurrency: currency }
@ -841,8 +832,11 @@ export class PortfolioService {
userId,
currency
);
const orders = await this.orderService.getOrders({ userId });
const fees = this.getFees(orders);
const orders = await this.orderService.getOrders({
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
@ -856,14 +850,17 @@ export class PortfolioService {
return {
...performanceInformation.performance,
dividend,
fees,
firstOrderDate,
netWorth,
totalBuy,
totalSell,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
}).length
};
}
@ -936,6 +933,47 @@ export class PortfolioService {
return cashPositions;
}
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
@ -964,7 +1002,11 @@ export class PortfolioService {
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const orders = await this.orderService.getOrders({ includeDrafts, userId });
const orders = await this.orderService.getOrders({
includeDrafts,
userId,
types: ['BUY', 'SELL']
});
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };

View File

@ -1,8 +1,9 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { MarketData, Prisma } from '@prisma/client';
import { DataSource, MarketData, Prisma } from '@prisma/client';
@Injectable()
export class MarketDataService {
@ -67,14 +68,20 @@ export class MarketDataService {
}
public async updateMarketData(params: {
data: Prisma.MarketDataUpdateInput;
data: { dataSource: DataSource } & UpdateMarketDataDto;
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.update({
data,
where
return this.prismaService.marketData.upsert({
where,
create: {
dataSource: data.dataSource,
date: where.date_symbol.date,
marketPrice: data.marketPrice,
symbol: where.date_symbol.symbol
},
update: { marketPrice: data.marketPrice }
});
}
}

View File

@ -1,5 +1,6 @@
<button
*ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button
(click)="onClickCloseButton()"
>

View File

@ -1,4 +1,7 @@
:host {
display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0;
padding: 0;
}

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string;
public hasPermissionToCreateOrder: boolean;
public positions: Position[];
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openDialog(params['symbol']);
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -64,12 +80,41 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.update();
}
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openDialog(aSymbol: string): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
locale: this.user?.settings?.locale,
symbol: aSymbol
},
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 });
});
}
private update() {
this.positions = undefined;
this.dataService
.fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,4 +1,12 @@
<div class="container justify-content-center pb-3 px-3">
<div class="container justify-content-center p-3">
<div class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card class="p-0">

View File

@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
exports: [],
imports: [
CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule,
GfToggleModule,
MatButtonModule,
MatCardModule,
RouterModule

View File

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -7,6 +6,7 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
@ -21,13 +21,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string;
public hasError: boolean;
public hasImpersonationId: boolean;

View File

@ -169,4 +169,18 @@
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Dividend</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.dividend"
></gf-value>
</div>
</div>
</div>

View File

@ -3,11 +3,13 @@ import {
ChangeDetectorRef,
Component,
Inject,
OnDestroy
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { AssetSubClass } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog implements OnDestroy {
export class PositionDetailDialog implements OnDestroy, OnInit {
public assetSubClass: AssetSubClass;
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
public name: string;
public netPerformance: number;
public netPerformancePercent: number;
public orders: OrderWithAccount[];
public quantity: number;
public quantityPrecision = 2;
public symbol: string;
@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy {
private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
) {}
public ngOnInit(): void {
this.dataService
.fetchPositionDetail(data.symbol)
.fetchPositionDetail(this.data.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy {
name,
netPerformance,
netPerformancePercent,
orders,
quantity,
symbol,
transactionCount,
@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy {
this.name = name;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.orders = orders;
this.quantity = quantity;
this.symbol = symbol;
this.transactionCount = transactionCount;

View File

@ -124,6 +124,20 @@
</div>
</div>
</div>
<gf-transactions-table
*ngIf="orders?.length > 0"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateOrder]="false"
[hasPermissionToFilter]="false"
[hasPermissionToImportOrders]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
[transactions]="orders"
></gf-transactions-table>
</div>
<gf-dialog-footer

View File

@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfTransactionsTableModule,
GfValueModule,
MatButtonModule,
MatDialogModule,

View File

@ -5,14 +5,9 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Position } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@Component({
selector: 'gf-position',
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['symbol'] === this.position?.symbol
) {
this.openDialog();
}
});
}
public constructor() {}
public ngOnInit() {}
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openDialog(): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol
},
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 });
});
}
}

View File

@ -14,13 +14,12 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
@Component({
selector: 'gf-positions-table',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { PositionsTableComponent } from './positions-table.component';

View File

@ -8,8 +8,7 @@ import {
Output
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { ToggleOption } from './interfaces/toggle-option.type';
import { ToggleOption } from '@ghostfolio/common/types';
@Component({
selector: 'gf-toggle',

View File

@ -1,4 +1,8 @@
<mat-form-field appearance="outline" class="w-100">
<mat-form-field
appearance="outline"
class="w-100"
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
>
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
<mat-chip-list #chipList aria-label="Search keywords">
<mat-chip
@ -73,11 +77,15 @@
<td mat-cell *matCellDef="let element" class="px-1">
<div
class="d-inline-flex p-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
[ngClass]="{
buy: element.type === 'BUY',
dividend: element.type === 'DIVIDEND',
sell: element.type === 'SELL'
}"
>
<ion-icon
[name]="
element.type === 'BUY'
element.type === 'BUY' || element.type === 'DIVIDEND'
? 'arrow-forward-circle-outline'
: 'arrow-back-circle-outline'
"
@ -179,6 +187,27 @@
</td>
</ng-container>
<ng-container matColumnDef="value">
<th
*matHeaderCellDef
class="justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
</th>
<td *matCellDef="let element" class="px1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.value"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
@ -254,12 +283,13 @@
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="
!row.isDraft &&
hasPermissionToOpenDetails &&
!row.isDraft &&
onOpenPositionDialog({
symbol: row.symbol
})
"
[ngClass]="{ 'is-draft': row.isDraft }"
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
></tr>
</table>

View File

@ -24,10 +24,6 @@
}
.mat-row {
&:not(.is-draft) {
cursor: pointer;
}
.type-badge {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
@ -41,6 +37,10 @@
color: var(--green);
}
&.dividend {
color: var(--blue);
}
&.sell {
color: var(--orange);
}

View File

@ -17,18 +17,15 @@ import {
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Router } from '@angular/router';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { endOfToday, format, isAfter } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
const SEARCH_STRING_SEPARATOR = ',';
@ -44,9 +41,12 @@ export class TransactionsTableComponent
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() hasPermissionToFilter = true;
@Input() hasPermissionToImportOrders: boolean;
@Input() hasPermissionToOpenDetails = true;
@Input() locale: string;
@Input() showActions: boolean;
@Input() showSymbolColumn = true;
@Input() transactions: OrderWithAccount[];
@Output() export = new EventEmitter<void>();
@ -77,21 +77,7 @@ export class TransactionsTableComponent
private allFilters: string[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
}
});
public constructor(private router: Router) {
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((keyword) => {
@ -150,6 +136,7 @@ export class TransactionsTableComponent
'quantity',
'unitPrice',
'fee',
'value',
'account'
];
@ -157,6 +144,12 @@ export class TransactionsTableComponent
this.displayedColumns.push('actions');
}
if (!this.showSymbolColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'symbol';
});
}
this.isLoading = true;
if (this.transactions) {
@ -210,27 +203,6 @@ export class TransactionsTableComponent
this.transactionToClone.emit(aTransaction);
}
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.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 });
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -14,7 +14,6 @@ import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { TransactionsTableComponent } from './transactions-table.component';
@ -24,7 +23,6 @@ import { TransactionsTableComponent } from './transactions-table.component';
imports: [
CommonModule,
GfNoTransactionsInfoModule,
GfPositionDetailDialogModule,
GfSymbolIconModule,
GfSymbolModule,
GfValueModule,

View File

@ -35,8 +35,8 @@
new feature, please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack channel"
>Slack channel</a
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
@ -108,12 +108,7 @@
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.activeUsers1d === undefined"
>
{{ statistics?.activeUsers1d || '-' }}
</h3>
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 24 hours)</small
@ -121,35 +116,7 @@
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.activeUsers7d === undefined"
>
{{ statistics?.activeUsers7d ?? '-' }}
</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 7 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.activeUsers30d === undefined"
>
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="statistics?.newUsers30d === undefined">
{{ statistics?.newUsers30d ?? '-' }}
</h3>
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
<div class="h6 mb-0">
<span i18n>New Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
@ -157,21 +124,23 @@
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.gitHubContributors === undefined"
>
{{ statistics?.gitHubContributors ?? '-' }}
</h3>
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Users in Slack community</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.gitHubStargazers === undefined"
>
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div>
</div>
</div>

View File

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
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';
@ -10,6 +9,7 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { ToggleOption } from '@ghostfolio/common/types';
import { AssetClass } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';

View File

@ -1,10 +1,10 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
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 { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ToggleOption } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@ -52,8 +52,9 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.transaction.type">
<mat-option value="BUY" i18n> BUY </mat-option>
<mat-option value="SELL" i18n> SELL </mat-option>
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
</mat-select>
</mat-form-field>
</div>
@ -141,7 +142,7 @@
[(ngModel)]="data.transaction.unitPrice"
/>
<button
*ngIf="currentMarketPrice"
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
mat-icon-button
matSuffix
title="Apply current market price"

View File

@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
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 { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
@ -73,6 +74,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
} else if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
}
});
}
@ -250,6 +255,27 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
});
}
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
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 });
});
}
public openUpdateTransactionDialog({
accountId,
currency,

View File

@ -212,8 +212,17 @@ export class DataService {
}
public fetchPositionDetail(aSymbol: string) {
return this.http.get<PortfolioPositionDetail>(
`/api/portfolio/position/${aSymbol}`
return this.http.get<any>(`/api/portfolio/position/${aSymbol}`).pipe(
map((data) => {
if (data.orders) {
for (const order of data.orders) {
order.createdAt = parseISO(order.createdAt);
order.date = parseISO(order.date);
}
}
return data;
})
);
}

View File

@ -214,10 +214,15 @@ export class ImportTransactionsService {
for (const key of ImportTransactionsService.TYPE_KEYS) {
if (item[key]) {
if (item[key].toLowerCase() === 'buy') {
return Type.BUY;
} else if (item[key].toLowerCase() === 'sell') {
return Type.SELL;
switch (item[key].toLowerCase()) {
case 'buy':
return Type.BUY;
case 'dividend':
return Type.DIVIDEND;
case 'sell':
return Type.SELL;
default:
break;
}
}
}

View File

@ -1,5 +1,15 @@
import { ToggleOption } from './types';
export const baseCurrency = 'USD';
export const defaultDateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
@ -35,6 +45,7 @@ export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';

View File

@ -3,6 +3,7 @@ import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance {
annualizedPerformancePercent: number;
cash: number;
dividend: number;
committedFunds: number;
fees: number;
firstOrderDate: Date;

View File

@ -1,8 +1,8 @@
export interface Statistics {
activeUsers1d: number;
activeUsers7d: number;
activeUsers30d: number;
gitHubContributors: number;
gitHubStargazers: number;
newUsers30d: number;
slackCommunityUsers: string;
}

View File

@ -4,6 +4,7 @@ import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type';
import { ToggleOption } from './toggle-option.type';
export type {
AccessWithGranteeUser,
@ -11,5 +12,6 @@ export type {
DateRange,
Granularity,
OrderWithAccount,
RequestWithUser
RequestWithUser,
ToggleOption
};

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.96.0",
"version": "1.98.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Type" ADD VALUE 'DIVIDEND';

View File

@ -206,5 +206,6 @@ enum Role {
enum Type {
BUY
DIVIDEND
SELL
}

View File

@ -1,2 +1,3 @@
Date,Code,Currency,Price,Quantity,Action,Fee
17/11/2021,MSFT,USD,0.62,5,dividend,0.00
16/09/2021,MSFT,USD,298.580,5,buy,19.00

1 Date Code Currency Price Quantity Action Fee
2 17/11/2021 MSFT USD 0.62 5 dividend 0.00
3 16/09/2021 MSFT USD 298.580 5 buy 19.00