Compare commits
65 Commits
Author | SHA1 | Date | |
---|---|---|---|
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f | |||
629f002074 | |||
7c65cf6ddd | |||
c38ebec3be | |||
2b8ab26e7e | |||
60f52bb209 | |||
616d168a7c | |||
b13e4425d3 | |||
1424236c48 | |||
2a605f850d | |||
88ffbfead0 | |||
5f4a8d505b | |||
e87b93f19c | |||
49dcade964 | |||
7cd65eed39 | |||
a51b210f79 | |||
285f2220f3 | |||
d72123246d | |||
3a78d6c3f1 | |||
d5e3ff5717 | |||
2efb331370 | |||
f521fe99c5 | |||
42306530b8 | |||
68c9d1b266 | |||
1ce90a0c06 | |||
50f6d154e5 | |||
e4c44faee4 | |||
5209f82cca | |||
292d345ce0 | |||
d58400788a | |||
7ff61ae839 | |||
b5b7af7741 | |||
de3e0fad83 | |||
8c8273c4d4 | |||
b406bcd17d | |||
fb496431e8 | |||
441b251536 | |||
1dbb5db611 | |||
8567efcd89 | |||
1cda5dcc0a | |||
3fb01c6dcf | |||
6a764fe893 | |||
d2b75a244c | |||
3611684f17 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,6 +5,7 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
@ -37,4 +38,4 @@ yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
115
CHANGELOG.md
115
CHANGELOG.md
@ -5,6 +5,115 @@ 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.221.0 - 2022-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage the tags in the create or edit activity dialog
|
||||
- Added the tags to the admin control panel
|
||||
- Added a blog post: _The importance of tracking your personal finances_
|
||||
- Resolved the title of the blog post
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import by a preview step
|
||||
- Improved the labels based on the type in the create or edit activity dialog
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Removed the data source type `RAKUTEN`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the date conversion for years with only two digits
|
||||
|
||||
## 1.220.0 - 2022-12-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
|
||||
- Added the `dryRun` option to the import activities endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
|
||||
- Upgraded `color` from version `4.0.1` to `4.2.3`
|
||||
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the rounding of the y-axis ticks in the benchmark comparator
|
||||
|
||||
## 1.219.0 - 2022-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to disable user sign up in the admin control panel
|
||||
- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the name to the symbol column in the activities table
|
||||
- Combined the name and symbol column in the holdings table (former positions table)
|
||||
|
||||
## 1.218.0 - 2022-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date of the first activity to the positions table
|
||||
- Added an endpoint to fetch the logo of an asset or a platform
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the asset profile details dialog in the admin control panel
|
||||
- Upgraded `chart.js` from version `3.8.0` to `4.0.1`
|
||||
|
||||
## 1.217.0 - 2022-12-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added the dividend timeline grouped by month
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the value redaction interceptor (including `comment`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
|
||||
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the activities sorting in the account detail dialog
|
||||
|
||||
## 1.216.0 - 2022-12-03
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a note for asset profiles
|
||||
- Supported a manual currency for the activity fee
|
||||
- Extended the support for column sorting in the accounts table (name, platform, transactions)
|
||||
- Extended the support for column sorting in the activities table (name, symbol)
|
||||
- Extended the support for column sorting in the positions table (performance)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `big.js` from version `6.1.1` to `6.2.1`
|
||||
- Upgraded `date-fns` from version `2.28.0` to `2.29.3`
|
||||
- Upgraded `replace-in-file` from version `6.2.0` to `6.3.5`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the filter by asset sub class for the asset profiles in the admin control
|
||||
|
||||
## 1.215.0 - 2022-11-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language selector on the account page
|
||||
- Improved the wording in the _X-ray_ section (net worth instead of investment)
|
||||
- Extended the asset profile details dialog in the admin control panel
|
||||
- Updated the browserslist database
|
||||
- Upgraded `ionicons` from version `5.5.1` to `6.0.4`
|
||||
- Upgraded `uuid` from version `8.3.2` to `9.0.0`
|
||||
|
||||
## 1.214.0 - 19.11.2022
|
||||
|
||||
### Added
|
||||
@ -32,6 +141,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added an indicator for excluded accounts in the accounts table
|
||||
- Added a blog post: _Black Friday 2022_
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ZAc` to `ZAR`)
|
||||
|
||||
## 1.212.0 - 11.11.2022
|
||||
|
||||
### Changed
|
||||
@ -523,7 +636,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Support a note for activities
|
||||
- Supported a note for activities
|
||||
|
||||
### Todo
|
||||
|
||||
|
@ -15,14 +15,16 @@
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -21,6 +22,7 @@ import {
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@ -33,6 +35,7 @@ import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
@ -332,6 +335,32 @@ export class AdminController {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
|
@ -116,6 +116,7 @@ export class AdminService {
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
Order: {
|
||||
@ -147,9 +148,10 @@ export class AdminService {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
activitiesCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
comment: symbolProfile.comment,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
@ -165,8 +167,14 @@ export class AdminService {
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
const [[assetProfile], marketData] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
@ -175,9 +183,37 @@ export class AdminService {
|
||||
symbol
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
assetProfile,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
comment,
|
||||
dataSource,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
|
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
[dataProvider: string]: string;
|
||||
};
|
||||
}
|
@ -22,10 +22,12 @@ import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
@ -52,10 +54,12 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
@ -58,18 +59,21 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
public googleLoginCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
const jwt: string = (<any>request.user).jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||
);
|
||||
} else {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||
|
@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -11,6 +12,7 @@ export class AuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -50,6 +52,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
@ -78,6 +87,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
|
@ -30,8 +30,8 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
|
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@Controller('exchange-rate')
|
||||
export class ExchangeRateController {
|
||||
public constructor(
|
||||
private readonly exchangeRateService: ExchangeRateService
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateController } from './exchange-rate.controller';
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExchangeRateController],
|
||||
exports: [ExchangeRateService],
|
||||
imports: [ExchangeRateDataModule],
|
||||
providers: [ExchangeRateService]
|
||||
})
|
||||
export class ExchangeRateModule {}
|
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateService {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||
) {}
|
||||
|
||||
public async getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
const [currency1, currency2] = symbol.split('-');
|
||||
|
||||
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
1,
|
||||
currency1,
|
||||
currency2,
|
||||
date
|
||||
);
|
||||
|
||||
return { marketPrice };
|
||||
}
|
||||
}
|
@ -3,8 +3,10 @@ import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@ -50,66 +52,88 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
|
||||
if (req.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (req.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
title = `500 Stars - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
} else if (req.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
title = `Hacktoberfest 2022 - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||
title = `Black Friday 2022 - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||
title = `The importance of tracking your personal finances - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'de',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'it',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -7,6 +8,7 @@ import {
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
@ -26,7 +28,10 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -45,12 +50,18 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
isDryRun,
|
||||
userCurrency,
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -19,6 +20,7 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
|
@ -1,30 +1,38 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
activities,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
}): Promise<Activity[]> {
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
@ -35,7 +43,7 @@ export class ImportService {
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
@ -46,57 +54,121 @@ export class ImportService {
|
||||
}
|
||||
);
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
date: dateString,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
|
||||
let order: OrderWithAccount;
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null
|
||||
},
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
@ -109,7 +181,7 @@ export class ImportService {
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
|
@ -8,6 +8,7 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
@ -103,6 +104,13 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { LogoService } from './logo.service';
|
||||
|
||||
@Controller('logo')
|
||||
export class LogoController {
|
||||
public constructor(private readonly logoService: LogoService) {}
|
||||
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getLogoByDataSourceAndSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
response.contentType('image/png');
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
public async getLogoByUrl(
|
||||
@Query('url') url: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByUrl(url);
|
||||
|
||||
response.contentType('image/png');
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogoController } from './logo.controller';
|
||||
import { LogoService } from './logo.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LogoController],
|
||||
imports: [ConfigurationModule, SymbolProfileModule],
|
||||
providers: [LogoService]
|
||||
})
|
||||
export class LogoModule {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
public constructor(
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return this.getBuffer(assetProfile.url);
|
||||
}
|
||||
|
||||
public async getLogoByUrl(aUrl: string) {
|
||||
return this.getBuffer(aUrl);
|
||||
}
|
||||
|
||||
private getBuffer(aUrl: string) {
|
||||
const get = bent(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
'GET',
|
||||
'buffer',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
return get();
|
||||
}
|
||||
}
|
@ -6,5 +6,6 @@ export interface Activities {
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
@ -39,8 +37,7 @@ export class OrderController {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -87,7 +84,7 @@ export class OrderController {
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
@ -95,20 +92,6 @@ export class OrderController {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return { activities };
|
||||
}
|
||||
|
||||
|
@ -362,6 +362,12 @@ export class OrderService {
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
// Remove existing tags
|
||||
await this.prismaService.order.update({
|
||||
data: { tags: { set: [] } },
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
@ -12,6 +12,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
@ -185,6 +186,55 @@ export class PortfolioController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
): Promise<PortfolioDividends> {
|
||||
let dividends: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
} else {
|
||||
dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
dividends = dividends.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxDividend
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
dividends = dividends.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@ -197,8 +247,8 @@ export class PortfolioController {
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy: 'month'
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
@ -323,6 +373,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
@ -372,6 +423,8 @@ export class PortfolioController {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
|
@ -67,6 +67,8 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
parseISO,
|
||||
set,
|
||||
@ -206,6 +208,44 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
groupBy?: GroupBy;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
userId,
|
||||
types: ['DIVIDEND'],
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency
|
||||
});
|
||||
|
||||
let dividends = activities.map((dividend) => {
|
||||
return {
|
||||
date: format(dividend.date, DATE_FORMAT),
|
||||
investment: dividend.valueInBaseCurrency
|
||||
};
|
||||
});
|
||||
|
||||
if (groupBy === 'month') {
|
||||
dividends = this.getDividendsByMonth(dividends);
|
||||
}
|
||||
|
||||
const startDate = this.getStartDate(
|
||||
dateRange,
|
||||
parseDate(dividends[0]?.date)
|
||||
);
|
||||
|
||||
return dividends.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
}
|
||||
|
||||
public async getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
@ -493,6 +533,7 @@ export class PortfolioService {
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
dateOfFirstActivity: parseDate(item.firstBuyDate),
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
@ -1204,6 +1245,49 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] {
|
||||
if (aDividends.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dividends = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
|
||||
for (const [index, dividend] of aDividends.entries()) {
|
||||
if (
|
||||
isSameMonth(parseDate(dividend.date), currentDate) &&
|
||||
isSameYear(parseDate(dividend.date), currentDate)
|
||||
) {
|
||||
// Same month: Add up divididends
|
||||
|
||||
investmentByMonth = investmentByMonth.plus(dividend.investment);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
|
||||
if (currentDate) {
|
||||
dividends.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(dividend.date);
|
||||
investmentByMonth = new Big(dividend.investment);
|
||||
}
|
||||
|
||||
if (index === aDividends.length - 1) {
|
||||
// Store current month (latest order)
|
||||
dividends.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dividends;
|
||||
}
|
||||
|
||||
private getFees({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
@ -1246,6 +1330,7 @@ export class PortfolioService {
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
dataSource: undefined,
|
||||
dateOfFirstActivity: undefined,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: balance,
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -86,9 +87,12 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
public async stripeCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
<string>request.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(
|
||||
@ -96,7 +100,7 @@ export class SubscriptionController {
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
|
@ -91,10 +91,19 @@ export class SymbolController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.symbolService.getForDate({
|
||||
const result = await this.symbolService.getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
@ -65,13 +64,9 @@ export class SymbolService {
|
||||
|
||||
public async getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
date = new Date(),
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -69,17 +69,14 @@ export class UserController {
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
|
||||
export class RedactValuesInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor() {}
|
||||
public constructor(private userService: UserService) {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
@ -23,7 +24,10 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
|
||||
if (hasImpersonationId) {
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
this.userService.isRestrictedView(request.user)
|
||||
) {
|
||||
if (data.accounts) {
|
||||
for (const accountId of Object.keys(data.accounts)) {
|
||||
if (data.accounts[accountId]?.balance !== undefined) {
|
||||
@ -38,6 +42,34 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
activity.Account.balance = null;
|
||||
}
|
||||
|
||||
if (activity.comment !== undefined) {
|
||||
activity.comment = null;
|
||||
}
|
||||
|
||||
if (activity.fee !== undefined) {
|
||||
activity.fee = null;
|
||||
}
|
||||
|
||||
if (activity.feeInBaseCurrency !== undefined) {
|
||||
activity.feeInBaseCurrency = null;
|
||||
}
|
||||
|
||||
if (activity.quantity !== undefined) {
|
||||
activity.quantity = null;
|
||||
}
|
||||
|
||||
if (activity.unitPrice !== undefined) {
|
||||
activity.unitPrice = null;
|
||||
}
|
||||
|
||||
if (activity.value !== undefined) {
|
||||
activity.value = null;
|
||||
}
|
||||
|
||||
if (activity.valueInBaseCurrency !== undefined) {
|
||||
activity.valueInBaseCurrency = null;
|
||||
}
|
||||
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
|
||||
if (accounts.length === 1) {
|
||||
return {
|
||||
evaluation: `All your investment is managed by a single account`,
|
||||
evaluation: `Your net worth is managed by a single account`,
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `Your investment is managed by ${accounts.length} accounts`,
|
||||
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
@ -114,9 +114,13 @@ export class DataProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
try {
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'DataProviderService');
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -209,7 +213,9 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
||||
`Fetched ${symbolsChunk.length} quote${
|
||||
symbolsChunk.length > 1 ? 's' : ''
|
||||
} from ${dataSource} in ${(
|
||||
(performance.now() - startTimeDataSource) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
@ -223,7 +229,7 @@ export class DataProviderService {
|
||||
|
||||
Logger.debug('------------------------------------------------');
|
||||
Logger.debug(
|
||||
`Fetched ${items.length} quotes in ${(
|
||||
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
|
@ -5,20 +5,18 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
@ -47,41 +45,6 @@ export class RapidApiService implements DataProviderInterface {
|
||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||
const fgi = await this.getFearAndGreedIndex();
|
||||
|
||||
try {
|
||||
// Rebuild historical data
|
||||
// TODO: can be removed after all data from the last year has been gathered
|
||||
// (introduced on 27.03.2021)
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
|
@ -206,6 +206,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||
// Convert ILS to ILA
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||
// Convert ZAR to ZAc (cents)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
}
|
||||
|
||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
@ -287,6 +290,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.mul(100)
|
||||
.toNumber()
|
||||
};
|
||||
} else if (
|
||||
symbol === `${this.baseCurrency}ZAR` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`)
|
||||
) {
|
||||
// Convert ZAR to ZAc (cents)
|
||||
response[`${this.baseCurrency}ZAc`] = {
|
||||
...response[symbol],
|
||||
currency: 'ZAc',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
.mul(100)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MarketDataModule } from './market-data.module';
|
||||
import { PrismaModule } from './prisma.module';
|
||||
|
||||
@Module({
|
||||
exports: [ExchangeRateDataService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule
|
||||
],
|
||||
providers: [ExchangeRateDataService],
|
||||
exports: [ExchangeRateDataService]
|
||||
providers: [ExchangeRateDataService]
|
||||
})
|
||||
export class ExchangeRateDataModule {}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { format, isToday } from 'date-fns';
|
||||
import { isNumber, uniq } from 'lodash';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { PropertyService } from './property/property.service';
|
||||
|
||||
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
@ -152,6 +154,53 @@ export class ExchangeRateDataService {
|
||||
return aValue;
|
||||
}
|
||||
|
||||
public async toCurrencyAtDate(
|
||||
aValue: number,
|
||||
aFromCurrency: string,
|
||||
aToCurrency: string,
|
||||
aDate: Date
|
||||
) {
|
||||
if (aValue === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isToday(aDate)) {
|
||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||
}
|
||||
|
||||
let factor = 1;
|
||||
|
||||
if (aFromCurrency !== aToCurrency) {
|
||||
const dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
const symbol = `${aFromCurrency}${aToCurrency}`;
|
||||
|
||||
const marketData = await this.marketDataService.get({
|
||||
dataSource,
|
||||
symbol,
|
||||
date: aDate
|
||||
});
|
||||
|
||||
if (marketData?.marketPrice) {
|
||||
factor = marketData?.marketPrice;
|
||||
} else {
|
||||
// TODO: Get from data provider service or calculate indirectly via base currency
|
||||
// and market data
|
||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(factor) && !isNaN(factor)) {
|
||||
return factor * aValue;
|
||||
}
|
||||
|
||||
// Fallback with error, if currencies are not available
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
return aValue;
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
let currencies: string[] = [];
|
||||
|
||||
|
@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
@ -20,14 +22,13 @@ export class MarketDataService {
|
||||
}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
dataSource,
|
||||
date = new Date(),
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<MarketData> {
|
||||
}: IDataGatheringItem): Promise<MarketData> {
|
||||
return await this.prismaService.marketData.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: resetHours(date)
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -39,6 +42,13 @@ export class PropertyService {
|
||||
return properties?.[aKey];
|
||||
}
|
||||
|
||||
public async isUserSignupEnabled() {
|
||||
return (
|
||||
((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async put({ key, value }: { key: string; value: string }) {
|
||||
return this.prismaService.property.upsert({
|
||||
create: { key, value },
|
||||
|
@ -8,25 +8,14 @@ import {
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
DataSource,
|
||||
Prisma,
|
||||
SymbolProfile,
|
||||
SymbolProfileOverrides
|
||||
} from '@prisma/client';
|
||||
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async delete({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
@ -43,7 +32,19 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
Order: {
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
@ -69,7 +70,12 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: symbolProfileIds.map((symbolProfileId) => {
|
||||
@ -89,7 +95,12 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
symbol: {
|
||||
in: symbols
|
||||
@ -99,22 +110,46 @@ export class SymbolProfileService {
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: { comment, symbolMapping },
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
|
||||
private getSymbols(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
_count: { Order: number };
|
||||
Order?: {
|
||||
date: Date;
|
||||
}[];
|
||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||
})[]
|
||||
): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => {
|
||||
const item = {
|
||||
...symbolProfile,
|
||||
activitiesCount: 0,
|
||||
countries: this.getCountries(
|
||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||
),
|
||||
dateOfFirstActivity: <Date>undefined,
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
};
|
||||
|
||||
item.activitiesCount = symbolProfile._count.Order;
|
||||
delete item._count;
|
||||
|
||||
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
|
||||
delete item.Order;
|
||||
|
||||
if (item.SymbolProfileOverrides) {
|
||||
item.assetClass =
|
||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||
|
@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { Inject, forwardRef } from '@angular/core';
|
||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { format, parse } from 'date-fns';
|
||||
import { addYears, format, getYear, parse } from 'date-fns';
|
||||
|
||||
export class CustomDateAdapter extends NativeDateAdapter {
|
||||
public constructor(
|
||||
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
||||
* Parses a date from a provided value
|
||||
*/
|
||||
public parse(aValue: string): Date {
|
||||
return parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
let date = parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
|
||||
if (getYear(date) < 1900) {
|
||||
if (getYear(date) > Number(format(new Date(), 'yy')) + 1) {
|
||||
date = addYears(date, 1900);
|
||||
} else {
|
||||
date = addYears(date, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
@ -109,6 +109,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
||||
).then((m) => m.BlackFriday2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -13,6 +13,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -27,7 +28,6 @@ import { GfHeaderModule } from './components/header/header.module';
|
||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageService } from './core/language.service';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
|
||||
export function NgxStripeFactory(): string {
|
||||
return environment.stripePublicKey;
|
||||
|
@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="orders?.length > 0" class="row">
|
||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||
<div class="col mb-3">
|
||||
<div class="h5 mb-0" i18n>Activities</div>
|
||||
<gf-activities-table
|
||||
@ -38,7 +38,7 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
|
@ -18,7 +18,9 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Platform?.url"
|
||||
@ -54,7 +56,12 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="platform">
|
||||
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="Platform.name"
|
||||
>
|
||||
<ng-container i18n>Platform</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
@ -76,7 +83,12 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1 text-right"
|
||||
mat-header-cell
|
||||
mat-sort-header="transactionCount"
|
||||
>
|
||||
<span class="d-block d-sm-none">#</span>
|
||||
<span class="d-none d-sm-block" i18n>Activities</span>
|
||||
</th>
|
||||
|
@ -3,12 +3,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
::ng-deep {
|
||||
.mat-form-field-infix {
|
||||
border-top: 0 solid transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
|
@ -13,6 +13,7 @@ import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { get } from 'lodash';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
@ -69,6 +70,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
if (this.accounts) {
|
||||
this.dataSource = new MatTableDataSource(this.accounts);
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.sortingDataAccessor = get;
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
@ -20,11 +20,14 @@ import {
|
||||
addDays,
|
||||
format,
|
||||
isBefore,
|
||||
isDate,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isValid,
|
||||
parse,
|
||||
parseISO
|
||||
} from 'date-fns';
|
||||
import { last } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@ -106,9 +109,15 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
const marketDataItems = [...missingMarketData, ...this.marketData];
|
||||
|
||||
if (!isToday(last(marketDataItems)?.date)) {
|
||||
marketDataItems.push({ date: new Date() });
|
||||
}
|
||||
|
||||
this.marketDataByMonth = {};
|
||||
|
||||
for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
|
||||
for (const marketDataItem of marketDataItems) {
|
||||
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
|
||||
const key = format(marketDataItem.date, 'yyyy-MM');
|
||||
|
||||
|
@ -13,11 +13,11 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -44,10 +44,10 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
AssetSubClass.PRECIOUS_METAL,
|
||||
AssetSubClass.PRIVATE_EQUITY,
|
||||
AssetSubClass.STOCK
|
||||
].map((id) => {
|
||||
].map((assetSubClass) => {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
id: assetSubClass,
|
||||
label: translate(assetSubClass),
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
});
|
||||
@ -63,10 +63,11 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activityCount',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'countriesCount',
|
||||
'sectorsCount',
|
||||
'countriesCount',
|
||||
'comment',
|
||||
'actions'
|
||||
];
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
@ -96,7 +97,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
) {
|
||||
this.openAssetProfileDialog({
|
||||
dataSource: params['dataSource'],
|
||||
dateOfFirstActivity: params['dateOfFirstActivity'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
@ -193,18 +193,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onOpenAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||
try {
|
||||
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
|
||||
} catch {}
|
||||
|
||||
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
dateOfFirstActivity,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetProfileDialog: true
|
||||
@ -219,11 +210,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
|
||||
private openAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
dateOfFirstActivity: string;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
@ -236,7 +225,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
autoFocus: false,
|
||||
data: <AssetProfileDialogParams>{
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
|
@ -64,12 +64,12 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="activityCount">
|
||||
<ng-container matColumnDef="activitiesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Activity Count</ng-container>
|
||||
<ng-container i18n>Activities Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.activityCount }}
|
||||
{{ element.activitiesCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -82,6 +82,15 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="sectorsCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Sectors Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.sectorsCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="countriesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Countries Count</ng-container>
|
||||
@ -91,12 +100,19 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="sectorsCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Sectors Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.sectorsCount }}
|
||||
<ng-container matColumnDef="comment">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<ion-icon
|
||||
*ngIf="element.comment"
|
||||
class="d-block"
|
||||
name="document-text-outline"
|
||||
></ion-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -146,7 +162,7 @@
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activityCount !== 0"
|
||||
[disabled]="element.activitiesCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
@ -160,7 +176,7 @@
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -6,9 +6,14 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
EnhancedSymbolProfile,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -23,51 +28,126 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetProfile: EnhancedSymbolProfile;
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ assetProfile, marketData }) => {
|
||||
this.assetProfile = assetProfile;
|
||||
this.countries = {};
|
||||
this.marketDataDetails = marketData;
|
||||
this.sectors = {};
|
||||
|
||||
if (assetProfile?.countries?.length > 0) {
|
||||
for (const country of assetProfile.countries) {
|
||||
this.countries[country.code] = {
|
||||
name: country.name,
|
||||
value: country.weight
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (assetProfile?.sectors?.length > 0) {
|
||||
for (const sector of assetProfile.sectors) {
|
||||
this.sectors[sector.name] = {
|
||||
name: sector.name,
|
||||
value: sector.weight
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||
});
|
||||
|
||||
this.assetProfileForm.markAsPristine();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminService
|
||||
.gatherSymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||
if (withRefresh) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
let symbolMapping = {};
|
||||
|
||||
try {
|
||||
symbolMapping = JSON.parse(
|
||||
this.assetProfileForm.controls['symbolMapping'].value
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
symbolMapping,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||
};
|
||||
|
||||
this.adminService
|
||||
.patchAssetProfile({
|
||||
...assetProfileData,
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.marketDataDetails = marketData;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,176 @@
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
position="center"
|
||||
[deviceType]="data.deviceType"
|
||||
[title]="data.symbol"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-header>
|
||||
<form
|
||||
class="d-flex flex-column h-100"
|
||||
[formGroup]="assetProfileForm"
|
||||
(keyup.enter)="assetProfileForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<div class="d-flex mb-3">
|
||||
<h1 class="flex-grow-1 m-0" mat-dialog-title>
|
||||
{{ assetProfile?.name ?? data.symbol }}
|
||||
</h1>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
type="button"
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item type="button" (click)="initialize()">
|
||||
<ng-container i18n>Refresh</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.dirty"
|
||||
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.dirty"
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<gf-admin-market-data-detail
|
||||
[dataSource]="data.dataSource"
|
||||
[dateOfFirstActivity]="data.dateOfFirstActivity"
|
||||
[locale]="data.locale"
|
||||
[marketData]="marketDataDetails"
|
||||
[symbol]="data.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
</div>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<gf-admin-market-data-detail
|
||||
class="mb-3"
|
||||
[dataSource]="data.dataSource"
|
||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||
[locale]="data.locale"
|
||||
[marketData]="marketDataDetails"
|
||||
[symbol]="data.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[isDate]="assetProfile?.dateOfFirstActivity ? true : false"
|
||||
[locale]="data.locale"
|
||||
[value]="assetProfile?.dateOfFirstActivity ?? '-'"
|
||||
>First Activity</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="assetProfile?.activitiesCount ?? 0"
|
||||
>Activities</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetClass"
|
||||
[value]="assetProfile?.assetClass"
|
||||
>Asset Class</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetSubClass"
|
||||
[value]="assetProfile?.assetSubClass"
|
||||
>Asset Sub Class</gf-value
|
||||
>
|
||||
</div>
|
||||
<ng-container
|
||||
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
|
||||
>
|
||||
<ng-container
|
||||
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts"
|
||||
>
|
||||
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="assetProfile?.sectors[0].name"
|
||||
>Sector</gf-value
|
||||
>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="assetProfile?.countries[0].name"
|
||||
>Country</gf-value
|
||||
>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #charts>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="h5" i18n>Sectors</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[maxItems]="10"
|
||||
[positions]="sectors"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="h5" i18n>Countries</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Symbol Mapping</mat-label>
|
||||
<textarea
|
||||
cdkTextareaAutosize
|
||||
formControlName="symbolMapping"
|
||||
matInput
|
||||
type="text"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Note</mat-label>
|
||||
<textarea
|
||||
cdkAutosizeMinRows="2"
|
||||
cdkTextareaAutosize
|
||||
formControlName="comment"
|
||||
matInput
|
||||
(keyup.enter)="$event.stopPropagation()"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-footer>
|
||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
|
||||
>
|
||||
<ng-container i18n>Save</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
|
||||
@ -12,11 +16,16 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
declarations: [AssetProfileDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAdminMarketDataDetailModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
ReactiveFormsModule,
|
||||
TextFieldModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface AssetProfileDialogParams {
|
||||
dateOfFirstActivity: string;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
locale: string;
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
PROPERTY_COUPONS,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
@ -35,6 +36,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForSystemMessage: boolean;
|
||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||
public info: InfoItem;
|
||||
public permissions = permissions;
|
||||
public transactionCount: number;
|
||||
public userCount: number;
|
||||
public user: User;
|
||||
@ -167,6 +169,13 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
value: aEvent.checked ? undefined : false
|
||||
});
|
||||
}
|
||||
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt($localize`Please set your system message:`);
|
||||
|
||||
@ -214,7 +223,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private putAdminSetting({ key, value }: { key: string; value: any }) {
|
||||
this.dataService
|
||||
.putAdminSetting(key, {
|
||||
value: value ? JSON.stringify(value) : undefined
|
||||
value: value || value === false ? JSON.stringify(value) : undefined
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
|
@ -72,16 +72,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div
|
||||
*ngIf="info?.benchmarks?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||
<tr *ngFor="let benchmark of info.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="info?.tags?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Tags</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let tag of info.tags">
|
||||
<td class="pl-1">{{ tag.name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Signup</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||
(change)="onEnableUserSignupModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
@ -109,16 +145,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="d-flex my-3 subscription"
|
||||
|
@ -37,6 +37,7 @@
|
||||
<gf-premium-indicator
|
||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
[enableLink]="false"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -159,10 +159,12 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
width: 1
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: 1,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
@ -173,17 +175,19 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
y: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
display: false
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
display: false
|
||||
},
|
||||
position: 'right',
|
||||
ticks: {
|
||||
callback: (value: number) => {
|
||||
return `${value} %`;
|
||||
return `${value.toFixed(2)} %`;
|
||||
},
|
||||
display: true,
|
||||
mirror: true,
|
||||
|
@ -1,9 +1,9 @@
|
||||
<mat-toolbar class="px-2">
|
||||
<ng-container *ngIf="user">
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo></gf-logo>
|
||||
</a>
|
||||
@ -289,7 +289,7 @@
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
<a
|
||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
|
@ -38,6 +38,7 @@ export class HeaderComponent implements OnChanges {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public hasPermissionToCreateUser: boolean;
|
||||
public impersonationId: string;
|
||||
public isMenuOpen: boolean;
|
||||
|
||||
@ -79,6 +80,11 @@ export class HeaderComponent implements OnChanges {
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateUser = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
}
|
||||
|
||||
public impersonateAccount(aId: string) {
|
||||
|
@ -27,7 +27,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 180;
|
||||
public readonly numberOfDays = 365;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
@ -48,6 +48,7 @@ import { last } from 'lodash';
|
||||
})
|
||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: InvestmentItem[] = [];
|
||||
@Input() benchmarkDataLabel = '';
|
||||
@Input() colorScheme: ColorScheme;
|
||||
@Input() currency: string;
|
||||
@Input() daysInMarket: number;
|
||||
@ -153,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
y: this.isInPercent ? investment * 100 : investment
|
||||
};
|
||||
}),
|
||||
label: $localize`Deposit`,
|
||||
label: this.benchmarkDataLabel,
|
||||
segment: {
|
||||
borderColor: (context: unknown) =>
|
||||
this.isInFuture(
|
||||
@ -194,6 +195,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
this.chart.options.plugins.tooltip = <unknown>(
|
||||
this.getTooltipPluginConfiguration()
|
||||
);
|
||||
this.chart.options.scales.x.min = this.daysInMarket
|
||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
||||
: undefined;
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
@ -257,13 +261,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
width: this.groupBy ? 0 : 1
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
min: this.daysInMarket
|
||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
||||
: undefined,
|
||||
suggestedMax: new Date().toISOString(),
|
||||
type: 'time',
|
||||
time: {
|
||||
tooltipFormat: getDateFormatString(this.locale),
|
||||
@ -271,12 +281,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
y: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
display: false
|
||||
},
|
||||
display: !this.isInPercent,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
display: false
|
||||
},
|
||||
position: 'right',
|
||||
ticks: {
|
||||
|
@ -223,7 +223,7 @@
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showSymbolColumn]="false"
|
||||
[showNameColumn]="false"
|
||||
(export)="onExport()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
|
@ -3,12 +3,12 @@
|
||||
<a
|
||||
class="d-flex p-3 w-100"
|
||||
[ngClass]="{ 'cursor-default': isLoading }"
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{
|
||||
dataSource: position?.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: position?.symbol
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<div class="d-flex mr-2">
|
||||
<gf-trend-indicator
|
||||
@ -39,7 +39,7 @@
|
||||
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
|
||||
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
|
||||
<div class="d-flex">
|
||||
<span>{{ position?.symbol | gfSymbol }}</span>
|
||||
<small class="text-muted">{{ position?.symbol | gfSymbol }}</small>
|
||||
</div>
|
||||
<div class="d-flex mt-1">
|
||||
<gf-value
|
||||
|
@ -1,152 +0,0 @@
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="allocationCurrent"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="icon">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.url"
|
||||
[tooltip]="element.name"
|
||||
[url]="element.url"
|
||||
></gf-symbol-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Symbol</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<ng-container *ngIf="element.name !== element.symbol">{{
|
||||
element.name
|
||||
}}</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
|
||||
<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="allocationCurrent">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Allocation</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.allocationCurrent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="performance">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Performance</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.netPerformancePercent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToShowValues &&
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass)
|
||||
}"
|
||||
(click)="
|
||||
hasPermissionToShowValues &&
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="px-4 py-3"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.data.length > pageSize && !isLoading"
|
||||
class="my-3 text-center"
|
||||
>
|
||||
<button mat-stroked-button (click)="onShowAllPositions()">
|
||||
<ng-container i18n>Show all</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
|
||||
"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
</div>
|
||||
|
||||
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>
|
@ -1,33 +0,0 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
::ng-deep {
|
||||
.mat-form-field-infix {
|
||||
border-top: 0 solid transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-form-field {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
<img
|
||||
*ngIf="url"
|
||||
src="https://www.google.com/s2/favicons?domain={{ url }}&sz=64"
|
||||
*ngIf="src"
|
||||
onerror="this.style.display='none'"
|
||||
[ngClass]="{ large: size === 'large' }"
|
||||
[src]="src"
|
||||
[title]="tooltip ? tooltip : ''"
|
||||
/>
|
||||
|
@ -2,8 +2,9 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnInit
|
||||
OnChanges
|
||||
} from '@angular/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-symbol-icon',
|
||||
@ -11,12 +12,22 @@ import {
|
||||
templateUrl: './symbol-icon.component.html',
|
||||
styleUrls: ['./symbol-icon.component.scss']
|
||||
})
|
||||
export class SymbolIconComponent implements OnInit {
|
||||
export class SymbolIconComponent implements OnChanges {
|
||||
@Input() dataSource: DataSource;
|
||||
@Input() size: 'large';
|
||||
@Input() symbol: string;
|
||||
@Input() tooltip: string;
|
||||
@Input() url: string;
|
||||
|
||||
public src: string;
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
public ngOnChanges() {
|
||||
if (this.dataSource && this.symbol) {
|
||||
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`;
|
||||
} else if (this.url) {
|
||||
this.src = `../api/v1/logo?url=${this.url}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -116,7 +116,6 @@
|
||||
<div class="align-items-center d-flex mb-2">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Language</div>
|
||||
<div class="hint-text text-muted" i18n>Beta</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field
|
||||
@ -132,9 +131,18 @@
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
<mat-option value="es">Español</mat-option>
|
||||
<mat-option value="it">Italiano</mat-option>
|
||||
<mat-option value="nl">Nederlands</mat-option>
|
||||
<mat-option value="es"
|
||||
>Español (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<mat-option value="it"
|
||||
>Italiano (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<mat-option value="nl"
|
||||
>Nederlands (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
@ -8,14 +7,5 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
templateUrl: './black-friday-2022-page.html'
|
||||
})
|
||||
export class BlackFriday2022PageComponent {
|
||||
public discount: number;
|
||||
|
||||
public constructor(private dataService: DataService) {
|
||||
const { subscriptions } = this.dataService.fetchInfo();
|
||||
|
||||
const coupon = subscriptions?.[0]?.coupon ?? 0;
|
||||
const price = subscriptions?.[0]?.price ?? 1;
|
||||
|
||||
this.discount = Math.floor((coupon / price) * 100) / 100;
|
||||
}
|
||||
public constructor() {}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Get {{ discount | percent }} off on our
|
||||
Get 75% off on our
|
||||
<strong>Ghostfolio Premium</strong>
|
||||
<gf-premium-indicator
|
||||
class="d-inline-block ml-1"
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: TheImportanceOfTrackingYourPersonalFinancesPageComponent,
|
||||
path: '',
|
||||
title: 'The importance of tracking your personal finances'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesRoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-the-importance-of-tracking-your-personal-finances-page',
|
||||
styleUrls: ['./the-importance-of-tracking-your-personal-finances-page.scss'],
|
||||
templateUrl: './the-importance-of-tracking-your-personal-finances-page.html'
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesPageComponent {}
|
@ -0,0 +1,168 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1">
|
||||
The importance of tracking your personal finances
|
||||
</h1>
|
||||
<div class="mb-3 text-muted"><small>2022-12-26</small></div>
|
||||
<img
|
||||
alt="The importance of tracking your personal finances Teaser"
|
||||
class="rounded w-100"
|
||||
src="../assets/images/blog/20221226.jpg"
|
||||
title="The importance of tracking your personal finances"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Once again, the end of the year is peak season for making
|
||||
resolutions, whether in the area of personal fitness, relationships
|
||||
or career. For sustainable changes, it’s important to track your
|
||||
progress and celebrate even small successes. Not surprisingly, these
|
||||
same principles apply to personal finances as well.
|
||||
</p>
|
||||
<p>
|
||||
Tracking your assets is an important part of achieving your
|
||||
financial goals. By regularly reviewing and monitoring your assets,
|
||||
you can get a clear picture of your financial situation and identify
|
||||
areas where action is required to reach your financial ambitions.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Understanding your net worth</h2>
|
||||
<p>
|
||||
One of the main benefits of monitoring wealth is that it helps you
|
||||
understand the value of your assets. By keeping track of your
|
||||
assets, you can see how much money you have invested, where it is
|
||||
invested, and how well it is performing. This allows you to see how
|
||||
your assets are performing over time, and you can adjust your
|
||||
strategy if necessary.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Identifying opportunities</h2>
|
||||
<p>
|
||||
Tracking your wealth can also help you identify financial
|
||||
opportunities for growth and improvement. By reviewing your assets
|
||||
regularly, you may be able to identify new investment opportunities
|
||||
or strategies that can help you grow your wealth and reach your
|
||||
financial objectives faster.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Managing the risk and making informed decisions</h2>
|
||||
<p>
|
||||
Monitoring your assets not only helps you understand the value of
|
||||
your assets and identify potential for growth, but also helps you
|
||||
manage your risk. By understanding the different types of your
|
||||
assets and their performance, you can make informed decisions about
|
||||
how to allocate your assets to minimize risk and maximize the
|
||||
potential return on investment.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Tracking personal finances with Ghostfolio</h2>
|
||||
<p>
|
||||
In summary, monitoring your assets is an important aspect of
|
||||
achieving your financial goals. It helps you understand the value of
|
||||
your assets, identify growth opportunities and manage your risk. By
|
||||
regularly reviewing and monitoring your assets, you can make
|
||||
informed decisions about your financial strategy and work toward
|
||||
your financial success.
|
||||
</p>
|
||||
<p>
|
||||
Instead of manually recording your assets and net worth in a
|
||||
spreadsheet, you can use
|
||||
<a href="https://ghostfol.io">Ghostfolio</a>, a web-based software
|
||||
to manage your personal finances.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4 py-3">
|
||||
<h2 class="h4 mb-0 text-center">
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
<p class="lead mb-2 text-center">
|
||||
Ghostfolio empowers you to keep track of your wealth.
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<a color="primary" href="https://ghostfol.io" mat-flat-button>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Assets</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Decision</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Goal</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Monitoring</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Net Worth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Opportunity</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Performance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio Tracker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Progress</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Risk</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Spreadsheet</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Success</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesRoutingModule } from './the-importance-of-tracking-your-personal-finances-page-routing.module';
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [TheImportanceOfTrackingYourPersonalFinancesPageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
RouterModule,
|
||||
TheImportanceOfTrackingYourPersonalFinancesRoutingModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesPageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -2,15 +2,41 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/12/the-importance-of-tracking-your-personal-finances"
|
||||
>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
The importance of tracking your personal finances
|
||||
</div>
|
||||
<div class="d-flex text-muted">2022-12-26</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card *ngIf="hasPermissionForSubscription" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/11/black-friday-2022"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Black Friday 2022</div>
|
||||
<div class="d-flex text-muted">2022-11-13</div>
|
||||
</div>
|
||||
@ -31,10 +57,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/10/hacktoberfest-2022"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hacktoberfest 2022</div>
|
||||
<div class="d-flex text-muted">2022-10-01</div>
|
||||
</div>
|
||||
@ -55,10 +81,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/08/500-stars-on-github"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">500 Stars on GitHub</div>
|
||||
<div class="d-flex text-muted">2022-08-18</div>
|
||||
</div>
|
||||
@ -79,10 +105,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
Ghostfolio meets Internet Identity
|
||||
</div>
|
||||
@ -105,10 +131,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/07/how-do-i-get-my-finances-in-order"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
How do I get my finances in order?
|
||||
</div>
|
||||
@ -131,10 +157,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
First months in Open Source
|
||||
</div>
|
||||
@ -157,10 +183,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2021/07/hello-ghostfolio"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
|
||||
<div class="d-flex text-muted">2021-07-31</div>
|
||||
</div>
|
||||
@ -181,10 +207,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../de/blog/2021/07/hallo-ghostfolio"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
|
||||
<div class="d-flex text-muted">2021-07-31</div>
|
||||
</div>
|
||||
|
@ -17,6 +17,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
public demoAuthToken: string;
|
||||
public deviceType: string;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionToCreateUser: boolean;
|
||||
public statistics: Statistics;
|
||||
public testimonials = [
|
||||
{
|
||||
@ -54,6 +55,11 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateUser = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.statistics = statistics;
|
||||
}
|
||||
|
||||
|
@ -31,15 +31,18 @@
|
||||
<div class="button-container mb-5 row">
|
||||
<div class="align-items-center col d-flex justify-content-center">
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="d-inline-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
<ng-container *ngIf="hasPermissionToCreateUser">
|
||||
<a
|
||||
class="d-inline-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
<div class="d-inline-block mx-3 text-muted">or</div></ng-container
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
<div class="d-inline-block mx-3 text-muted">or</div>
|
||||
|
||||
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
|
||||
Live Demo
|
||||
</a>
|
||||
@ -306,7 +309,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-5">
|
||||
<div *ngIf="hasPermissionToCreateUser" class="row my-5">
|
||||
<div class="col">
|
||||
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
|
||||
<p class="lead mb-3 text-center">
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
@ -15,14 +17,15 @@ import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, AssetSubClass, Type } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
import { EMPTY, Observable, Subject } from 'rxjs';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil
|
||||
@ -38,7 +41,8 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'create-or-update-activity-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
@ViewChild('autocomplete') autocomplete;
|
||||
@ViewChild('symbolAutocomplete') symbolAutocomplete;
|
||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
@ -51,8 +55,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public currentMarketPrice = null;
|
||||
public filteredLookupItems: LookupItem[];
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
||||
public filteredTagsObservable: Observable<Tag[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
public total = 0;
|
||||
public Validators = Validators;
|
||||
|
||||
@ -72,10 +79,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.locale = this.data.user?.settings?.locale;
|
||||
this.dateAdapter.setLocale(this.locale);
|
||||
|
||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||
const { currencies, platforms, tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.currencies = currencies;
|
||||
this.platforms = platforms;
|
||||
this.tags = tags;
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
accountId: [this.data.activity?.accountId, Validators.required],
|
||||
@ -86,12 +94,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
currencyOfFee: [
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
dataSource: [
|
||||
this.data.activity?.SymbolProfile?.dataSource,
|
||||
Validators.required
|
||||
],
|
||||
date: [this.data.activity?.date, Validators.required],
|
||||
fee: [this.data.activity?.fee, Validators.required],
|
||||
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
|
||||
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
|
||||
quantity: [this.data.activity?.quantity, Validators.required],
|
||||
searchSymbol: [
|
||||
@ -108,7 +121,36 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
this.activityForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
.subscribe(async () => {
|
||||
let exchangeRate = 1;
|
||||
|
||||
const currency = this.activityForm.controls['currency'].value;
|
||||
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
|
||||
const date = this.activityForm.controls['date'].value;
|
||||
|
||||
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
|
||||
try {
|
||||
const { marketPrice } = await lastValueFrom(
|
||||
this.dataService
|
||||
.fetchExchangeRateForDate({
|
||||
date,
|
||||
symbol: `${currencyOfFee}-${currency}`
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
);
|
||||
|
||||
exchangeRate = marketPrice;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const feeInCustomCurrency =
|
||||
this.activityForm.controls['feeInCustomCurrency'].value *
|
||||
exchangeRate;
|
||||
|
||||
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
|
||||
emitEvent: false
|
||||
});
|
||||
|
||||
if (
|
||||
this.activityForm.controls['type'].value === 'BUY' ||
|
||||
this.activityForm.controls['type'].value === 'ITEM'
|
||||
@ -123,6 +165,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.controls['unitPrice'].value -
|
||||
this.activityForm.controls['fee'].value ?? 0;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||
@ -149,6 +193,15 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
})
|
||||
);
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.controls['tags'].value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags ? this.filterTags(aTags) : this.tags.slice();
|
||||
})
|
||||
);
|
||||
|
||||
this.activityForm.controls['type'].valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((type: Type) => {
|
||||
@ -160,6 +213,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.controls['currency'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['currencyOfFee'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['dataSource'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
@ -189,6 +245,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.activityForm.controls['type'].setValue(this.data.activity?.type);
|
||||
@ -223,6 +281,16 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
return aLookupItem?.symbol ?? '';
|
||||
}
|
||||
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.controls['tags'].setValue([
|
||||
...(this.activityForm.controls['tags'].value ?? []),
|
||||
this.tags.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onBlurSymbol() {
|
||||
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
|
||||
return (
|
||||
@ -242,10 +310,18 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onRemoveTag(aTag: Tag) {
|
||||
this.activityForm.controls['tags'].setValue(
|
||||
this.activityForm.controls['tags'].value.filter(({ id }) => {
|
||||
return id !== aTag.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
const activity: CreateOrderDto | UpdateOrderDto = {
|
||||
accountId: this.activityForm.controls['accountId'].value,
|
||||
@ -286,6 +362,16 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map((tag) => {
|
||||
return tag.id;
|
||||
});
|
||||
|
||||
return this.tags.filter((tag) => {
|
||||
return !tagIds.includes(tag.id);
|
||||
});
|
||||
}
|
||||
|
||||
private updateSymbol(symbol: string) {
|
||||
this.isLoading = true;
|
||||
|
||||
@ -313,6 +399,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
)
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.activityForm.controls['currency'].setValue(currency);
|
||||
this.activityForm.controls['currencyOfFee'].setValue(currency);
|
||||
this.activityForm.controls['dataSource'].setValue(dataSource);
|
||||
|
||||
this.currentMarketPrice = marketPrice;
|
||||
|
@ -11,10 +11,10 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
<mat-option i18n value="BUY">BUY</mat-option>
|
||||
<mat-option i18n value="DIVIDEND">DIVIDEND</mat-option>
|
||||
<mat-option i18n value="ITEM">ITEM</mat-option>
|
||||
<mat-option i18n value="SELL">SELL</mat-option>
|
||||
<mat-option i18n value="BUY">Buy</mat-option>
|
||||
<mat-option i18n value="DIVIDEND">Dividend</mat-option>
|
||||
<mat-option i18n value="ITEM">Item</mat-option>
|
||||
<mat-option i18n value="SELL">Sell</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@ -41,11 +41,11 @@
|
||||
autocorrect="off"
|
||||
formControlName="searchSymbol"
|
||||
matInput
|
||||
[matAutocomplete]="autocomplete"
|
||||
[matAutocomplete]="symbolAutocomplete"
|
||||
(blur)="onBlurSymbol()"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#autocomplete="matAutocomplete"
|
||||
#symbolAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
@ -109,7 +109,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Unit Price</mat-label>
|
||||
<mat-label
|
||||
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
||||
<ng-container *ngSwitchCase="'DIVIDEND'" i18n
|
||||
>Dividend</ng-container
|
||||
>
|
||||
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
<input formControlName="unitPrice" matInput type="number" />
|
||||
<span class="ml-2" matSuffix
|
||||
>{{ activityForm.controls['currency'].value }}</span
|
||||
@ -127,6 +135,23 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
||||
<div
|
||||
class="ml-2"
|
||||
matSuffix
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
|
||||
>
|
||||
<mat-select formControlName="currencyOfFee">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency">
|
||||
{{ currency }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</div>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="fee" matInput type="number" />
|
||||
@ -177,16 +202,38 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div
|
||||
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
|
||||
>
|
||||
<div [ngClass]="{ 'd-none': tags?.length <= 0 }">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-list>
|
||||
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value">
|
||||
<mat-chip-list #tagsChipList>
|
||||
<mat-chip
|
||||
*ngFor="let tag of activityForm.controls['tags']?.value"
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
(removed)="onRemoveTag(tag)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||
</mat-chip>
|
||||
<input
|
||||
#tagInput
|
||||
name="close-outline"
|
||||
[matAutocomplete]="autocompleteTags"
|
||||
[matChipInputFor]="tagsChipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
/>
|
||||
</mat-chip-list>
|
||||
<mat-autocomplete
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let tag of filteredTagsObservable | async"
|
||||
[value]="tag.id"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -19,9 +19,9 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
||||
declarations: [CreateOrUpdateActivityDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolModule,
|
||||
GfValueModule,
|
||||
FormsModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
|
@ -20,6 +20,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mat-chip {
|
||||
cursor: pointer;
|
||||
min-height: 1.5rem !important;
|
||||
}
|
||||
|
||||
.mat-form-field-appearance-outline {
|
||||
::ng-deep {
|
||||
.mat-form-field-suffix {
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { isArray } from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -20,8 +21,11 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'import-activities-dialog.html'
|
||||
})
|
||||
export class ImportActivitiesDialog implements OnDestroy {
|
||||
public activities: Activity[] = [];
|
||||
public details: any[] = [];
|
||||
public errorMessages: string[] = [];
|
||||
public isFileSelected = false;
|
||||
public selectedActivities: Activity[] = [];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -39,13 +43,47 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
public async onImportActivities() {
|
||||
try {
|
||||
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
||||
|
||||
await this.importActivitiesService.importSelectedActivities(
|
||||
this.selectedActivities
|
||||
);
|
||||
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Import has been completed`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.snackBar.open(
|
||||
$localize`Oops! Something went wrong.` +
|
||||
' ' +
|
||||
$localize`Please try again later.`,
|
||||
$localize`Okay`,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} finally {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
public onReset() {
|
||||
this.details = [];
|
||||
this.errorMessages = [];
|
||||
this.isFileSelected = false;
|
||||
}
|
||||
|
||||
public onSelectFile() {
|
||||
const input = document.createElement('input');
|
||||
input.accept = 'application/JSON, .csv';
|
||||
input.type = 'file';
|
||||
|
||||
input.onchange = (event) => {
|
||||
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
||||
this.snackBar.open('⏳ ' + $localize`Validating data...`);
|
||||
|
||||
// Getting the file reference
|
||||
const file = (event.target as HTMLInputElement).files[0];
|
||||
@ -80,11 +118,10 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.importActivitiesService.importJson({
|
||||
content: content.activities
|
||||
this.activities = await this.importActivitiesService.importJson({
|
||||
content: content.activities,
|
||||
isDryRun: true
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({ error, activities: content.activities });
|
||||
@ -93,12 +130,11 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
try {
|
||||
await this.importActivitiesService.importCsv({
|
||||
this.activities = await this.importActivitiesService.importCsv({
|
||||
fileContent,
|
||||
isDryRun: true,
|
||||
userAccounts: this.data.user.accounts
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
@ -119,6 +155,10 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
activities: [],
|
||||
error: { error: { message: ['Unexpected format'] } }
|
||||
});
|
||||
} finally {
|
||||
this.isFileSelected = true;
|
||||
this.snackBar.dismiss();
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -126,9 +166,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
input.click();
|
||||
}
|
||||
|
||||
public onReset() {
|
||||
this.details = [];
|
||||
this.errorMessages = [];
|
||||
public updateSelection(data: Activity[]) {
|
||||
this.selectedActivities = data;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -143,8 +182,6 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
activities: any[];
|
||||
error: any;
|
||||
}) {
|
||||
this.snackBar.dismiss();
|
||||
|
||||
this.errorMessages = error?.error?.message;
|
||||
|
||||
for (const message of this.errorMessages) {
|
||||
@ -161,16 +198,4 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
private handleImportSuccess() {
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Import has been completed`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,13 @@
|
||||
></gf-dialog-header>
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<ng-container *ngIf="errorMessages.length === 0">
|
||||
<ng-container *ngIf="!isFileSelected">
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<button
|
||||
class="py-3"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
(click)="onImport()"
|
||||
(click)="onSelectFile()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Choose File</span>
|
||||
@ -33,37 +33,68 @@
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="errorMessages.length > 0">
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel
|
||||
*ngFor="let message of errorMessages; let i = index"
|
||||
[disabled]="!details[i]"
|
||||
>
|
||||
<mat-expansion-panel-header class="pl-1">
|
||||
<mat-panel-title>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex mr-2">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
<ng-container *ngIf="isFileSelected">
|
||||
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
|
||||
<gf-activities-table
|
||||
[activities]="activities"
|
||||
[baseCurrency]="data?.user?.settings?.baseCurrency"
|
||||
[deviceType]="data?.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="false"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data?.user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
[showCheckbox]="true"
|
||||
[showSymbolColumn]="false"
|
||||
(selectedActivities)="updateSelection($event)"
|
||||
></gf-activities-table>
|
||||
</ng-container>
|
||||
<ng-template #errorMessage>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel
|
||||
*ngFor="let message of errorMessages; let i = index"
|
||||
[disabled]="!details[i]"
|
||||
>
|
||||
<mat-expansion-panel-header class="pl-1">
|
||||
<mat-panel-title>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex mr-2">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
</div>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<pre
|
||||
*ngIf="details[i]"
|
||||
class="m-0"
|
||||
><code>{{ details[i] | json }}</code></pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="mt-2">
|
||||
<button mat-button (click)="onReset()">
|
||||
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
|
||||
<span i18n>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<pre
|
||||
*ngIf="details[i]"
|
||||
class="m-0"
|
||||
><code>{{ details[i] | json }}</code></pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="mt-2">
|
||||
<button mat-button (click)="onReset()">
|
||||
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
|
||||
<span i18n>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isFileSelected" class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[disabled]="!selectedActivities?.length"
|
||||
(click)="onImportActivities()"
|
||||
>
|
||||
<ng-container i18n>Import</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
|
@ -5,6 +5,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
||||
import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
|
||||
@ -12,6 +13,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
declarations: [ImportActivitiesDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
MatButtonModule,
|
||||
|
@ -22,7 +22,7 @@ import { Market, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { Account, AssetClass, DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
@ -71,7 +71,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
| 'value'
|
||||
>;
|
||||
};
|
||||
public routeQueryParams: Subscription;
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
@ -98,7 +97,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['accountId'] && params['accountDetailDialog']) {
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
@ -9,8 +13,9 @@ import {
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -30,9 +35,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||
public daysInMarket: number;
|
||||
public deviceType: string;
|
||||
public dividendsByMonth: InvestmentItem[];
|
||||
public dividendTimelineDataLabel = $localize`Dividend`;
|
||||
public firstOrderDate: Date;
|
||||
public hasImpersonationId: boolean;
|
||||
public investments: InvestmentItem[];
|
||||
public investmentTimelineDataLabel = $localize`Deposit`;
|
||||
public investmentsByMonth: InvestmentItem[];
|
||||
public isLoadingBenchmarkComparator: boolean;
|
||||
public isLoadingInvestmentChart: boolean;
|
||||
@ -42,6 +50,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
];
|
||||
public performanceDataItems: HistoricalDataItem[];
|
||||
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
||||
public portfolioEvolutionDataLabel = $localize`Deposit`;
|
||||
public top3: Position[];
|
||||
public user: User;
|
||||
|
||||
@ -50,12 +59,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private dialog: MatDialog,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
const { benchmarks } = this.dataService.fetchInfo();
|
||||
this.benchmarks = benchmarks;
|
||||
|
||||
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() {
|
||||
@ -124,6 +151,47 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: <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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
this.isLoadingInvestmentChart = true;
|
||||
@ -165,6 +233,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchDividends({
|
||||
groupBy: 'month',
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ dividends }) => {
|
||||
this.dividendsByMonth = dividends;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchInvestments({
|
||||
groupBy: 'month',
|
||||
|
@ -35,20 +35,30 @@
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div *ngFor="let position of top3; let i = index" class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div *ngFor="let position of top3; let i = index" class="py-1">
|
||||
<a
|
||||
class="d-flex"
|
||||
[queryParams]="{
|
||||
dataSource: position.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: position.symbol
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<ngx-skeleton-loader
|
||||
@ -71,23 +81,30 @@
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div
|
||||
*ngFor="let position of bottom3; let i = index"
|
||||
class="d-flex py-1"
|
||||
>
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div *ngFor="let position of bottom3; let i = index" class="py-1">
|
||||
<a
|
||||
class="d-flex"
|
||||
[queryParams]="{
|
||||
dataSource: position.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: position.symbol
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<ngx-skeleton-loader
|
||||
@ -121,6 +138,7 @@
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[benchmarkDataItems]="investments"
|
||||
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[historicalDataItems]="performanceDataItems"
|
||||
@ -133,7 +151,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="mb-5 row">
|
||||
<div class="col-lg">
|
||||
<div class="align-items-center d-flex mb-4">
|
||||
<div
|
||||
@ -158,6 +176,7 @@
|
||||
class="h-100"
|
||||
groupBy="month"
|
||||
[benchmarkDataItems]="investmentsByMonth"
|
||||
[benchmarkDataLabel]="investmentTimelineDataLabel"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
@ -168,4 +187,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="align-items-center d-flex mb-4">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
|
||||
>
|
||||
<span i18n>Dividend Timeline</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="mode"
|
||||
[isLoading]="false"
|
||||
[options]="modeOptions"
|
||||
(change)="onChangeGroupBy($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
groupBy="month"
|
||||
[benchmarkDataItems]="dividendsByMonth"
|
||||
[benchmarkDataLabel]="dividendTimelineDataLabel"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[locale]="user?.settings?.locale"
|
||||
[range]="user?.settings?.dateRange"
|
||||
></gf-investment-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,7 +16,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
@ -36,7 +36,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
public placeholder = '';
|
||||
public portfolioDetails: PortfolioDetails;
|
||||
public positionsArray: PortfolioPosition[];
|
||||
public routeQueryParams: Subscription;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -51,7 +50,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (
|
||||
|
@ -12,13 +12,13 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<gf-positions-table
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positionsArray"
|
||||
></gf-positions-table>
|
||||
></gf-holdings-table>
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder && positionsArray?.length > 0"
|
||||
class="text-center"
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
|
||||
|
||||
import { HoldingsPageRoutingModule } from './holdings-page-routing.module';
|
||||
import { HoldingsPageComponent } from './holdings-page.component';
|
||||
@ -12,7 +12,7 @@ import { HoldingsPageComponent } from './holdings-page.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfPositionsTableModule,
|
||||
GfHoldingsTableModule,
|
||||
HoldingsPageRoutingModule,
|
||||
MatButtonModule
|
||||
],
|
||||
|
@ -1,16 +1,3 @@
|
||||
<div
|
||||
*ngIf="!user || user?.subscription?.type === 'Basic'"
|
||||
class="intro mb-5 py-5"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="font-weight-bold m-0 text-center">Black Friday Deal</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
@ -11,20 +11,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.intro {
|
||||
background-color: rgb(var(--dark-primary-text));
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
|
||||
h1 {
|
||||
font-size: 4vw;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
font-size: 10vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&:hover,
|
||||
&.active {
|
||||
@ -36,8 +22,4 @@
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
|
||||
.intro {
|
||||
background-color: rgba(var(--light-dividers));
|
||||
}
|
||||
}
|
||||
|
@ -115,12 +115,12 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<gf-positions-table
|
||||
<gf-holdings-table
|
||||
pageSize="7"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToShowValues]="false"
|
||||
[positions]="positionsArray"
|
||||
></gf-positions-table>
|
||||
></gf-holdings-table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
|
@ -2,8 +2,8 @@ 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 { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -14,8 +14,8 @@ import { PublicPageComponent } from './public-page.component';
|
||||
declarations: [PublicPageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfHoldingsTableModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfPositionsTableModule,
|
||||
GfValueModule,
|
||||
GfWorldMapChartModule,
|
||||
MatButtonModule,
|
||||
|
@ -25,6 +25,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
public demoAuthToken: string;
|
||||
public deviceType: string;
|
||||
public hasPermissionForSocialLogin: boolean;
|
||||
public hasPermissionToCreateUser: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public info: InfoItem;
|
||||
|
||||
@ -52,6 +53,10 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
globalPermissions,
|
||||
permissions.enableSocialLogin
|
||||
);
|
||||
this.hasPermissionToCreateUser = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
}
|
||||
|
||||
public async createAccount() {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user