Compare commits

...

65 Commits

Author SHA1 Message Date
da827a08f5 Release 1.221.0 (#1541) 2022-12-26 17:09:51 +01:00
d545e4877c Feature/improve activities import by preview step (#1540)
* Improve activities import

* Update changelog
2022-12-26 17:07:51 +01:00
1918dee9c5 Format code 2022-12-26 16:50:50 +01:00
a08610b603 Feature/improve activities import (#1531) 2022-12-26 16:26:51 +01:00
c22733db56 Feature/resolve blog post titles (#1539)
* Resolve blog post titles

* Update changelog
2022-12-26 16:23:21 +01:00
ee4866eb7d Feature/add blog post the importance of tracking your personal finances (#1537)
* Add blog post: The importance of tracking your personal finances

* Update changelog
2022-12-26 12:23:43 +01:00
327b1fa0d7 Feature/refresh cryptocurrencies list 20221225 (#1536)
* Update cryptocurrencies.json

* Update changelog
2022-12-25 18:17:35 +01:00
b155666d21 Feature/improve label based on type in create or edit activity dialog (#1535)
* Improve label

* Update changelog
2022-12-25 12:41:48 +01:00
c5ee3237ed Refactoring (#1533) 2022-12-25 12:40:29 +01:00
16118d635c Bugfix/fix date conversion of two digit year (#1529)
* Fix date conversion of two digit year

* Update changelog
2022-12-25 12:38:40 +01:00
49ce4803ce Feature/add support to manage tags in create or edit activity dialog (#1532)
* Add support to manage tags

* Update changelog
2022-12-25 12:23:52 +01:00
0b65d05013 Feature/remove rakuten from data source type (#1534)
* Remove RAKUTEN

* Update changelog
2022-12-25 12:20:09 +01:00
8793284e75 Feature/add tags to admin control panel (#1530)
* Add tags

* Update changelog
2022-12-24 12:28:17 +01:00
1c5e4050a8 Release 1.220.0 (#1528) 2022-12-23 11:08:22 +01:00
4f187e1a9f Feature/increase fear and greed index to 365 days (#1519)
* Increase fear and greed index to 365 days

* Update changelog
2022-12-23 11:06:52 +01:00
b56111ae85 Feature/add dry run to import api endpoint (#1526)
* Add dry run to import API endpoint

* Update changelog
2022-12-23 10:51:49 +01:00
61dfc1f819 Feature/upgrade prettier to version 2.8.1 (#1523)
* Upgrade prettier to version 2.8.1

* Update changelog
2022-12-23 10:50:57 +01:00
6137f228a8 Upgrade @types/lodash to version 4.14.191 (#1527) 2022-12-23 10:50:38 +01:00
5293de14cd Bugfix/fix rounding of y axis ticks in benchmark comparator (#1521)
* Fix rounding

* Update changelog
2022-12-22 12:27:34 +01:00
7340a674b5 Feature/upgrade color to version 4.2.3 (#1524)
* Upgrade color to version 4.2.3

* Update changelog
2022-12-21 19:40:29 +01:00
42cb3e2c73 Harmonize style of symbol (#1520) 2022-12-20 13:29:57 +01:00
e8a4a53c9f Feature/support position detail dialog in top performers of analysis page (#1522)
* Add support for position detail dialog

* Update changelog
2022-12-19 09:36:34 +01:00
629f002074 Release 1.219.0 (#1518) 2022-12-17 21:22:26 +01:00
7c65cf6ddd Update locales (#1517) 2022-12-17 21:20:51 +01:00
c38ebec3be Feature/add name to activities table (#1516)
* Add name to symbol column

* Update changelog
2022-12-17 21:14:58 +01:00
2b8ab26e7e Feature/combine name and symbol column in holdings table (#1514)
* Combine name and symbol column

* Update changelog
2022-12-17 20:50:39 +01:00
60f52bb209 Handle user signup for OAuth and Internet Identity (#1515)
Co-Authored-By: gobdevel <99349192+gobdevel@users.noreply.github.com>
2022-12-17 16:18:00 +01:00
616d168a7c Feature/add signup permission (#1512)
* Added support to disable user sign up in the admin control panel

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-17 12:39:21 +01:00
b13e4425d3 Feature/extend glossary by deflation inflation and stagflation (#1511)
* Extend glossary by deflation, inflation and stagflation

* Update changelog
2022-12-16 17:38:19 +01:00
1424236c48 Add "Buy me a coffee" badge (#1509) 2022-12-15 17:26:30 +01:00
2a605f850d Refactor requests and responses (#1507) 2022-12-14 08:03:38 +01:00
88ffbfead0 Add purpose (#1508) 2022-12-13 12:56:10 +01:00
5f4a8d505b Release 1.218.0 (#1510) 2022-12-12 20:15:32 +01:00
e87b93f19c Feature/add logo endpoint (#1506)
* Add logo endpoint

* Update changelog
2022-12-12 20:13:45 +01:00
49dcade964 Feature/add date of first activity to holdings (#1505)
* Add date of first activity

* Update changelog
2022-12-11 10:19:35 +01:00
7cd65eed39 Feature/improve asset profile details dialog (#1504)
* Improve asset profile details dialog

* Update changelog
2022-12-11 09:37:07 +01:00
a51b210f79 Feature/upgrade chart.js to version 4.0.1 (#1503)
* Upgrade chart.js to version 4.0.1 (including plugins)

* Migrate border attributes

* Update changelog
2022-12-10 16:47:09 +01:00
285f2220f3 Release 1.217.0 (#1502) 2022-12-10 14:32:28 +01:00
d72123246d Feature/upgrade cheerio to version 1.0.0 rc.12 (#1501)
* Upgrade cheerio to version 1.0.0-rc.12

* Update changelog
2022-12-10 12:19:45 +01:00
3a78d6c3f1 Feature/add dividend timeline (#1498)
* Add dividend timeline

* Update changelog

Co-Authored-By: João Pereira <joao@jpereira.me>
2022-12-10 11:54:49 +01:00
d5e3ff5717 Add locale (#1500) 2022-12-09 15:06:15 +01:00
2efb331370 Feature/improve value redaction interceptor (#1495)
* Improve value redaction interceptor

* Update changelog
2022-12-07 17:48:46 +01:00
f521fe99c5 Feature/upgrade prisma to version 4.7.1 (#1493)
* Upgrade prisma to version 4.7.1

* Update changelog
2022-12-06 09:03:28 +01:00
42306530b8 New strings translated to Spanish (#1496)
* Update messages.es.xlf

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-04 20:02:02 +01:00
68c9d1b266 Bugfix/fix activities sorting in account detail dialog (#1494)
* Fix activities sorting

* Update changelog
2022-12-04 18:54:41 +01:00
1ce90a0c06 Release 1.216.0 (#1492) 2022-12-03 19:19:10 +01:00
50f6d154e5 Feature/extend sorting in tables (#1491)
* Extend sorting

* Update changelog
2022-12-03 19:17:30 +01:00
e4c44faee4 Fix sorting by performance field in positions table (#1489) 2022-12-03 18:25:53 +01:00
5209f82cca Feature/upgrade replace in file to version 6.3.5 (#1486)
* Upgrade replace-in-file to version 6.3.5

* Update changelog
2022-12-03 18:23:40 +01:00
292d345ce0 Feature/support manual currency for fee (#1490)
* Support manual currency for fee

* Update changelog
2022-12-03 18:22:19 +01:00
d58400788a Feature/upgrade big.js to version 6.2.1 (#1488)
* Upgrade big.js to version 6.2.1

* Update changelog
2022-12-02 17:56:11 +01:00
7ff61ae839 Feature/upgrade date fns to version 2.29.3 (#1487)
* Upgrade date-fns to version 2.29.3

* Update changelog
2022-12-01 17:14:45 +01:00
b5b7af7741 Feature/improve asset profile management (#1485)
* Improve asset profile management (Add note, fix filter)

* Update changelog
2022-11-30 20:01:17 +01:00
de3e0fad83 Remove link (#1484) 2022-11-29 23:19:07 +01:00
8c8273c4d4 Release 1.215.0 (#1483) 2022-11-27 10:34:17 +01:00
b406bcd17d Feature/update browserslist database 20221127 (#1482)
* Update browserslist database

* Update changelog
2022-11-27 10:32:22 +01:00
fb496431e8 Clean up (#1481) 2022-11-27 10:21:21 +01:00
441b251536 Setup form (#1474)
* Setup form to patch asset profile (for symbolMapping)
2022-11-27 10:19:34 +01:00
1dbb5db611 Feature/improve wording in single account rule (#1479)
* Improve wording

* Update changelog
2022-11-26 08:53:01 +01:00
8567efcd89 Feature/upgrade ionicons to version 6.0.4 (#1478)
* Upgrade ionicons to version 6.0.4

* Update changelog
2022-11-25 20:49:26 +01:00
1cda5dcc0a Feature/upgrade UUID to version 9.0.0 (#1476)
* Upgrade uuid to version 9.0.0

* Update changelog
2022-11-24 20:33:29 +01:00
3fb01c6dcf Added yarn cache to .gitignore (#1477)
* Added yarn cache (.yarn) to .gitignore
2022-11-24 11:46:26 +01:00
6a764fe893 Convert between ZAc and ZAR (#1471)
* Convert between ZAc and ZAR

* Update changelog
2022-11-22 20:18:38 +01:00
d2b75a244c Feature/improve language selector (#1466)
* Improve language selector

* Update changelog
2022-11-21 20:39:52 +01:00
3611684f17 Feature/extend asset profile details dialog (#1469)
* Extend asset profile details dialog

* Update changelog
2022-11-21 20:14:36 +01:00
136 changed files with 5181 additions and 1999 deletions

3
.gitignore vendored
View File

@ -5,6 +5,7 @@
/tmp /tmp
# dependencies # dependencies
/.yarn
/node_modules /node_modules
# IDEs and editors # IDEs and editors
@ -37,4 +38,4 @@ yarn-error.log
# System Files # System Files
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@ -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/), 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). 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 ## 1.214.0 - 19.11.2022
### Added ### 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 an indicator for excluded accounts in the accounts table
- Added a blog post: _Black Friday 2022_ - 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 ## 1.212.0 - 11.11.2022
### Changed ### Changed
@ -523,7 +636,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Support a note for activities - Supported a note for activities
### Todo ### Todo

View File

@ -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> <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>
<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"> <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"> <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> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p> </p>
</div> </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;"> <div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk"> <a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">

View File

@ -9,6 +9,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -21,6 +22,7 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
Patch,
Post, Post,
Put, Put,
Query, Query,
@ -33,6 +35,7 @@ import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
@ -332,6 +335,32 @@ export class AdminController {
return this.adminService.deleteProfileData({ dataSource, symbol }); 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') @Put('settings/:key')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateProperty( public async updateProperty(

View File

@ -116,6 +116,7 @@ export class AdminService {
}, },
assetClass: true, assetClass: true,
assetSubClass: true, assetSubClass: true,
comment: true,
countries: true, countries: true,
dataSource: true, dataSource: true,
Order: { Order: {
@ -147,9 +148,10 @@ export class AdminService {
countriesCount, countriesCount,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activityCount: symbolProfile._count.Order, activitiesCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date, date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol symbol: symbolProfile.symbol
@ -165,8 +167,14 @@ export class AdminService {
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: UniqueAsset): Promise<AdminMarketDataDetails> {
return { const [[assetProfile], marketData] = await Promise.all([
marketData: await this.marketDataService.marketDataItems({ this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
@ -175,9 +183,37 @@ export class AdminService {
symbol 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) { public async putSetting(key: string, value: string) {
let response: Property; let response: Property;

View 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;
};
}

View File

@ -22,10 +22,12 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware'; import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
@ -52,10 +54,12 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,

View File

@ -16,6 +16,7 @@ import {
Version Version
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -58,18 +59,21 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) { public googleLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = req.user.jwt; const jwt: string = (<any>request.user).jwt;
if (jwt) { if (jwt) {
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
); );
} else { } else {
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth` )}/${DEFAULT_LANGUAGE_CODE}/auth`

View File

@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],

View File

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.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 { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
@ -11,6 +12,7 @@ export class AuthService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -50,6 +52,13 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
provider, provider,
@ -78,6 +87,13 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
provider, provider,

View File

@ -30,8 +30,8 @@ export class BenchmarkController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,

View 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
});
}
}

View 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 {}

View 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 };
}
}

View File

@ -3,8 +3,10 @@ import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
@Injectable() @Injectable()
@ -50,66 +52,88 @@ export class FrontendMiddleware implements NestMiddleware {
} catch {} } 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 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'; 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'; 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'; 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 ( if (
req.path.startsWith('/api/') || request.path.startsWith('/api/') ||
this.isFileRequest(req.url) || this.isFileRequest(request.url) ||
!this.isProduction !this.isProduction
) { ) {
// Skip // Skip
next(); next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) { } else if (request.path === '/de' || request.path.startsWith('/de/')) {
res.send( response.send(
this.interpolate(this.indexHtmlDe, { this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'de', languageCode: 'de',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/es' || req.path.startsWith('/es/')) { } else if (request.path === '/es' || request.path.startsWith('/es/')) {
res.send( response.send(
this.interpolate(this.indexHtmlEs, { this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'es', languageCode: 'es',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/it' || req.path.startsWith('/it/')) { } else if (request.path === '/it' || request.path.startsWith('/it/')) {
res.send( response.send(
this.interpolate(this.indexHtmlIt, { this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'it', languageCode: 'it',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) { } else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
res.send( response.send(
this.interpolate(this.indexHtmlNl, { this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'nl', languageCode: 'nl',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else { } else {
res.send( response.send(
this.interpolate(this.indexHtmlEn, { this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -7,6 +8,7 @@ import {
Inject, Inject,
Logger, Logger,
Post, Post,
Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -26,7 +28,10 @@ export class ImportController {
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @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')) { if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -45,12 +50,18 @@ export class ImportController {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
const userCurrency = this.request.user.Settings.settings.baseCurrency;
try { try {
return await this.importService.import({ const activities = await this.importService.import({
maxActivitiesToImport, maxActivitiesToImport,
activities: importData.activities, isDryRun,
userCurrency,
activitiesDto: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); Logger.error(error, ImportController);

View File

@ -5,6 +5,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -19,6 +20,7 @@ import { ImportService } from './import.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule

View File

@ -1,30 +1,38 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; 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 { 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 { 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 { 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() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService private readonly orderService: OrderService
) {} ) {}
public async import({ public async import({
activities, activitiesDto,
isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
userCurrency: string;
userId: string; userId: string;
}): Promise<void> { }): Promise<Activity[]> {
for (const activity of activities) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM') {
activity.dataSource = 'MANUAL'; activity.dataSource = 'MANUAL';
@ -35,7 +43,7 @@ export class ImportService {
} }
await this.validateActivities({ await this.validateActivities({
activities, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport,
userId userId
}); });
@ -46,57 +54,121 @@ export class ImportService {
} }
); );
const activities: Activity[] = [];
for (const { for (const {
accountId, accountId,
comment, comment,
currency, currency,
dataSource, dataSource,
date, date: dateString,
fee, fee,
quantity, quantity,
symbol, symbol,
type, type,
unitPrice unitPrice
} of activities) { } of activitiesDto) {
await this.orderService.createOrder({ const date = parseISO(<string>(<unknown>dateString));
comment, const validatedAccountId = accountIds.includes(accountId)
fee, ? accountId
quantity, : undefined;
type,
unitPrice, let order: OrderWithAccount;
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined, if (isDryRun) {
date: parseISO(<string>(<unknown>date)), order = {
SymbolProfile: { comment,
connectOrCreate: { date,
create: { fee,
currency, quantity,
dataSource, type,
symbol unitPrice,
}, userId,
where: { accountId: validatedAccountId,
dataSource_symbol: { 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, dataSource,
symbol 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({ private async validateActivities({
activities, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
userId: string; userId: string;
}) { }) {
if (activities?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
} }
@ -109,7 +181,7 @@ export class ImportService {
for (const [ for (const [
index, index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } { currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of activities.entries()) { ] of activitiesDto.entries()) {
const duplicateActivity = existingActivities.find((activity) => { const duplicateActivity = existingActivities.find((activity) => {
return ( return (
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&

View File

@ -8,6 +8,7 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID, DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
@ -103,6 +104,13 @@ export class InfoService {
)) as string; )) as string;
} }
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
return { return {
...info, ...info,
globalPermissions, globalPermissions,

View 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();
}
}
}

View 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 {}

View 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();
}
}

View File

@ -6,5 +6,6 @@ export interface Activities {
export interface Activity extends OrderWithAccount { export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number; feeInBaseCurrency: number;
value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;
} }

View File

@ -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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.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'; 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 apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -87,7 +84,7 @@ export class OrderController {
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
let activities = await this.orderService.getOrders({ const activities = await this.orderService.getOrders({
filters, filters,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
@ -95,20 +92,6 @@ export class OrderController {
withExcludedAccounts: true withExcludedAccounts: true
}); });
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
}
return { activities }; return { activities };
} }

View File

@ -362,6 +362,12 @@ export class OrderService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
data: { tags: { set: [] } },
where
});
return this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,

View File

@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);

View File

@ -12,6 +12,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, 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') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@ -197,8 +247,8 @@ export class PortfolioController {
if (groupBy === 'month') { if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments({ investments = await this.portfolioService.getInvestments({
dateRange, dateRange,
impersonationId, groupBy,
groupBy: 'month' impersonationId
}); });
} else { } else {
investments = await this.portfolioService.getInvestments({ investments = await this.portfolioService.getInvestments({
@ -323,6 +373,7 @@ export class PortfolioController {
} }
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(
@Param('accessId') accessId @Param('accessId') accessId
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
@ -372,6 +423,8 @@ export class PortfolioController {
allocationCurrent: portfolioPosition.value / totalValue, allocationCurrent: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name, name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent, netPerformancePercent: portfolioPosition.netPerformancePercent,

View File

@ -67,6 +67,8 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth,
isSameYear,
max, max,
parseISO, parseISO,
set, 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({ public async getInvestments({
dateRange, dateRange,
impersonationId, impersonationId,
@ -493,6 +533,7 @@ export class PortfolioService {
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
dateOfFirstActivity: parseDate(item.firstBuyDate),
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, 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({ private getFees({
date = new Date(0), date = new Date(0),
orders, orders,
@ -1246,6 +1330,7 @@ export class PortfolioService {
assetSubClass: AssetClass.CASH, assetSubClass: AssetClass.CASH,
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: balance, investment: balance,

View File

@ -21,6 +21,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -86,9 +87,12 @@ export class SubscriptionController {
} }
@Get('stripe/callback') @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( const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId <string>request.query.checkoutSessionId
); );
Logger.log( Logger.log(
@ -96,7 +100,7 @@ export class SubscriptionController {
'SubscriptionController' 'SubscriptionController'
); );
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account` )}/${DEFAULT_LANGUAGE_CODE}/account`

View File

@ -91,10 +91,19 @@ export class SymbolController {
); );
} }
return this.symbolService.getForDate({ const result = await this.symbolService.getForDate({
dataSource, dataSource,
date, date,
symbol symbol
}); });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

View File

@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
@ -65,13 +64,9 @@ export class SymbolService {
public async getForDate({ public async getForDate({
dataSource, dataSource,
date, date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
date, date,

View File

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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 { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -69,17 +69,14 @@ export class UserController {
@Post() @Post()
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { const isUserSignupEnabled =
const isReadOnlyMode = (await this.propertyService.getByKey( await this.propertyService.isUserSignupEnabled();
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) { if (!isUserSignupEnabled) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
}
} }
const hasAdmin = await this.userService.hasAdmin(); const hasAdmin = await this.userService.hasAdmin();

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
CallHandler, CallHandler,
ExecutionContext, ExecutionContext,
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
{ {
public constructor() {} public constructor(private userService: UserService) {}
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
@ -23,7 +24,10 @@ export class RedactValuesInResponseInterceptor<T>
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id']; const hasImpersonationId = !!request.headers?.['impersonation-id'];
if (hasImpersonationId) { if (
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
) {
if (data.accounts) { if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) { for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) { if (data.accounts[accountId]?.balance !== undefined) {
@ -38,6 +42,34 @@ export class RedactValuesInResponseInterceptor<T>
activity.Account.balance = null; 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; return activity;
}); });
} }

View File

@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
if (accounts.length === 1) { if (accounts.length === 1) {
return { return {
evaluation: `All your investment is managed by a single account`, evaluation: `Your net worth is managed by a single account`,
value: false value: false
}; };
} }
return { return {
evaluation: `Your investment is managed by ${accounts.length} accounts`, evaluation: `Your net worth is managed by ${accounts.length} accounts`,
value: true value: true
}; };
} }

View File

@ -114,9 +114,13 @@ export class DataProviderService {
} }
} }
const allData = await Promise.all(promises); try {
for (const { data, symbol } of allData) { const allData = await Promise.all(promises);
result[symbol] = data; for (const { data, symbol } of allData) {
result[symbol] = data;
}
} catch (error) {
Logger.error(error, 'DataProviderService');
} }
return result; return result;
@ -209,7 +213,9 @@ export class DataProviderService {
} }
Logger.debug( Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${( `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`
@ -223,7 +229,7 @@ export class DataProviderService {
Logger.debug('------------------------------------------------'); Logger.debug('------------------------------------------------');
Logger.debug( Logger.debug(
`Fetched ${items.length} quotes in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`

View File

@ -5,20 +5,18 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; 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 { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent'; import bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format } from 'date-fns';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService
private readonly prismaService: PrismaService
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -47,41 +45,6 @@ export class RapidApiService implements DataProviderInterface {
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); 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 { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {

View File

@ -206,6 +206,9 @@ export class YahooFinanceService implements DataProviderInterface {
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber(); 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)] = { response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
@ -287,6 +290,18 @@ export class YahooFinanceService implements DataProviderInterface {
.mul(100) .mul(100)
.toNumber() .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()
};
} }
} }

View File

@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MarketDataModule } from './market-data.module';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
@Module({ @Module({
exports: [ExchangeRateDataService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule PropertyModule
], ],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService]
exports: [ExchangeRateDataService]
}) })
export class ExchangeRateDataModule {} export class ExchangeRateDataModule {}

View File

@ -1,12 +1,13 @@
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format, isToday } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service'; import { PropertyService } from './property/property.service';
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -152,6 +154,53 @@ export class ExchangeRateDataService {
return aValue; 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[]> { private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = []; let currencies: string[] = [];

View File

@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
import { IDataGatheringItem } from './interfaces/interfaces';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -20,14 +22,13 @@ export class MarketDataService {
} }
public async get({ public async get({
date, dataSource,
date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<MarketData> {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prismaService.marketData.findFirst({ return await this.prismaService.marketData.findFirst({
where: { where: {
dataSource,
symbol, symbol,
date: resetHours(date) date: resetHours(date)
} }

View File

@ -1,5 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; 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'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@ -39,6 +42,13 @@ export class PropertyService {
return properties?.[aKey]; 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 }) { public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({ return this.prismaService.property.upsert({
create: { key, value }, create: { key, value },

View File

@ -8,25 +8,14 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ public async delete({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
@ -43,7 +32,19 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
Order: {
orderBy: {
date: 'asc'
},
select: { date: true },
take: 1
},
SymbolProfileOverrides: true
},
where: { where: {
AND: [ AND: [
{ {
@ -69,7 +70,12 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
},
where: { where: {
id: { id: {
in: symbolProfileIds.map((symbolProfileId) => { in: symbolProfileIds.map((symbolProfileId) => {
@ -89,7 +95,12 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
},
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -99,22 +110,46 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .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( private getSymbols(
symbolProfiles: (SymbolProfile & { symbolProfiles: (SymbolProfile & {
_count: { Order: number };
Order?: {
date: Date;
}[];
SymbolProfileOverrides: SymbolProfileOverrides; SymbolProfileOverrides: SymbolProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const item = { const item = {
...symbolProfile, ...symbolProfile,
activitiesCount: 0,
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: <Date>undefined,
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(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) { if (item.SymbolProfileOverrides) {
item.assetClass = item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass; item.SymbolProfileOverrides.assetClass ?? item.assetClass;

View File

@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper'; 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 { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: string): Date { 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;
} }
} }

View File

@ -109,6 +109,13 @@ const routes: Routes = [
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module' './pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule) ).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', path: 'demo',
loadChildren: () => loadChildren: () =>

View File

@ -13,6 +13,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { MaterialCssVarsModule } from 'angular-material-css-vars'; import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -27,7 +28,6 @@ import { GfHeaderModule } from './components/header/header.module';
import { authInterceptorProviders } from './core/auth.interceptor'; import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service'; import { LanguageService } from './core/language.service';
import { ServiceWorkerModule } from '@angular/service-worker';
export function NgxStripeFactory(): string { export function NgxStripeFactory(): string {
return environment.stripePublicKey; return environment.stripePublicKey;

View File

@ -30,7 +30,7 @@
</div> </div>
</div> </div>
<div *ngIf="orders?.length > 0" class="row"> <div class="row" [ngClass]="{ 'd-none': !orders?.length }">
<div class="col mb-3"> <div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div> <div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table <gf-activities-table
@ -38,7 +38,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"

View File

@ -18,7 +18,9 @@
</ng-container> </ng-container>
<ng-container matColumnDef="account"> <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> <td *matCellDef="let element" class="px-1" mat-cell>
<gf-symbol-icon <gf-symbol-icon
*ngIf="element.Platform?.url" *ngIf="element.Platform?.url"
@ -54,7 +56,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <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> <ng-container i18n>Platform</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
@ -76,7 +83,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <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-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Activities</span> <span class="d-none d-sm-block" i18n>Activities</span>
</th> </th>

View File

@ -3,12 +3,6 @@
:host { :host {
display: block; display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-table { .mat-table {
td { td {
&.mat-footer-cell { &.mat-footer-cell {

View File

@ -13,6 +13,7 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { get } from 'lodash';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@Component({ @Component({
@ -69,6 +70,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
if (this.accounts) { if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts); this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.isLoading = false; this.isLoading = false;
} }

View File

@ -20,11 +20,14 @@ import {
addDays, addDays,
format, format,
isBefore, isBefore,
isDate,
isSameDay, isSameDay,
isToday,
isValid, isValid,
parse, parse,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { last } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; 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 = {}; this.marketDataByMonth = {};
for (const marketDataItem of [...missingMarketData, ...this.marketData]) { for (const marketDataItem of marketDataItems) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10); const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM'); const key = format(marketDataItem.date, 'yyyy-MM');

View File

@ -13,11 +13,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.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 { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource } from '@prisma/client'; import { AssetSubClass, DataSource } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@ -44,10 +44,10 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
AssetSubClass.PRECIOUS_METAL, AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY, AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK AssetSubClass.STOCK
].map((id) => { ].map((assetSubClass) => {
return { return {
id, id: assetSubClass,
label: id, label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS' type: 'ASSET_SUB_CLASS'
}; };
}); });
@ -63,10 +63,11 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
'assetClass', 'assetClass',
'assetSubClass', 'assetSubClass',
'date', 'date',
'activityCount', 'activitiesCount',
'marketDataItemCount', 'marketDataItemCount',
'countriesCount',
'sectorsCount', 'sectorsCount',
'countriesCount',
'comment',
'actions' 'actions'
]; ];
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
@ -96,7 +97,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
) { ) {
this.openAssetProfileDialog({ this.openAssetProfileDialog({
dataSource: params['dataSource'], dataSource: params['dataSource'],
dateOfFirstActivity: params['dateOfFirstActivity'],
symbol: params['symbol'] symbol: params['symbol']
}); });
} }
@ -193,18 +193,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onOpenAssetProfileDialog({ public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
dataSource,
dateOfFirstActivity,
symbol
}: UniqueAsset & { dateOfFirstActivity: string }) {
try {
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
} catch {}
this.router.navigate([], { this.router.navigate([], {
queryParams: { queryParams: {
dateOfFirstActivity,
dataSource, dataSource,
symbol, symbol,
assetProfileDialog: true assetProfileDialog: true
@ -219,11 +210,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
dateOfFirstActivity,
symbol symbol
}: { }: {
dataSource: DataSource; dataSource: DataSource;
dateOfFirstActivity: string;
symbol: string; symbol: string;
}) { }) {
this.userService this.userService
@ -236,7 +225,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
autoFocus: false, autoFocus: false,
data: <AssetProfileDialogParams>{ data: <AssetProfileDialogParams>{
dataSource, dataSource,
dateOfFirstActivity,
symbol, symbol,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale

View File

@ -64,12 +64,12 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="activityCount"> <ng-container matColumnDef="activitiesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <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> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }} {{ element.activitiesCount }}
</td> </td>
</ng-container> </ng-container>
@ -82,6 +82,15 @@
</td> </td>
</ng-container> </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"> <ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container> <ng-container i18n>Countries Count</ng-container>
@ -91,12 +100,19 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="sectorsCount"> <ng-container matColumnDef="comment">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th
<ng-container i18n>Sectors Count</ng-container> *matHeaderCellDef
</th> class="px-1"
<td *matCellDef="let element" class="px-1 text-right" mat-cell> mat-header-cell
{{ element.sectorsCount }} 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> </td>
</ng-container> </ng-container>
@ -146,7 +162,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activityCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Delete</ng-container> <ng-container i18n>Delete</ng-container>
@ -160,7 +176,7 @@
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
class="cursor-pointer" class="cursor-pointer"
mat-row mat-row
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })" (click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
></tr> ></tr>
</table> </table>
</div> </div>

View File

@ -6,9 +6,14 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { 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 { MarketData } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -23,51 +28,126 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'] styleUrls: ['./asset-profile-dialog.component.scss']
}) })
export class AssetProfileDialog implements OnDestroy, OnInit { 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 marketDataDetails: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
};
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams private formBuilder: FormBuilder
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
this.initialize(); 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 { public onClose(): void {
this.dialogRef.close(); 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) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); 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
});
}
} }

View File

@ -1,24 +1,176 @@
<gf-dialog-header <form
mat-dialog-title class="d-flex flex-column h-100"
position="center" [formGroup]="assetProfileForm"
[deviceType]="data.deviceType" (keyup.enter)="assetProfileForm.valid && onSubmit()"
[title]="data.symbol" (ngSubmit)="onSubmit()"
(closeButtonClicked)="onClose()" >
></gf-dialog-header> <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> <div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="data.dataSource" class="mb-3"
[dateOfFirstActivity]="data.dateOfFirstActivity" [dataSource]="data.dataSource"
[locale]="data.locale" [dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
[marketData]="marketDataDetails" [locale]="data.locale"
[symbol]="data.symbol" [marketData]="marketDataDetails"
(marketDataChanged)="onMarketDataChanged($event)" [symbol]="data.symbol"
></gf-admin-market-data-detail> (marketDataChanged)="onMarketDataChanged($event)"
</div> ></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 <div class="d-flex justify-content-end" mat-dialog-actions>
mat-dialog-actions <button i18n mat-button type="button" (click)="onClose()">Cancel</button>
[deviceType]="data.deviceType" <button
(closeButtonClicked)="onClose()" color="primary"
></gf-dialog-footer> mat-flat-button
type="submit"
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -1,10 +1,14 @@
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; 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 { 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 { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfValueModule } from '@ghostfolio/ui/value';
import { AssetProfileDialog } from './asset-profile-dialog.component'; import { AssetProfileDialog } from './asset-profile-dialog.component';
@ -12,11 +16,16 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
declarations: [AssetProfileDialog], declarations: [AssetProfileDialog],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfAdminMarketDataDetailModule, GfAdminMarketDataDetailModule,
GfDialogFooterModule, GfPortfolioProportionChartModule,
GfDialogHeaderModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule MatDialogModule,
MatInputModule,
MatMenuModule,
ReactiveFormsModule,
TextFieldModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -1,7 +1,6 @@
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams { export interface AssetProfileDialogParams {
dateOfFirstActivity: string;
dataSource: DataSource; dataSource: DataSource;
deviceType: string; deviceType: string;
locale: string; locale: string;

View File

@ -7,6 +7,7 @@ import {
PROPERTY_COUPONS, PROPERTY_COUPONS,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED,
PROPERTY_SYSTEM_MESSAGE PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces'; import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
@ -35,6 +36,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean; public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; public info: InfoItem;
public permissions = permissions;
public transactionCount: number; public transactionCount: number;
public userCount: number; public userCount: number;
public user: User; 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() { public onSetSystemMessage() {
const systemMessage = prompt($localize`Please set your system message:`); 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 }) { private putAdminSetting({ key, value }: { key: string; value: any }) {
this.dataService this.dataService
.putAdminSetting(key, { .putAdminSetting(key, {
value: value ? JSON.stringify(value) : undefined value: value || value === false ? JSON.stringify(value) : undefined
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {

View File

@ -72,16 +72,52 @@
</div> </div>
</div> </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" i18n>Benchmarks</div>
<div class="w-50"> <div class="w-50">
<table> <table>
<tr *ngFor="let benchmark of info?.benchmarks"> <tr *ngFor="let benchmark of info.benchmarks">
<td class="pl-1">{{ benchmark.symbol }}</td> <td class="pl-1">{{ benchmark.symbol }}</td>
</tr> </tr>
</table> </table>
</div> </div>
</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 *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>System Message</div> <div class="w-50" i18n>System Message</div>
<div class="w-50"> <div class="w-50">
@ -109,16 +145,6 @@
</button> </button>
</div> </div>
</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 <div
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
class="d-flex my-3 subscription" class="d-flex my-3 subscription"

View File

@ -37,6 +37,7 @@
<gf-premium-indicator <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false"
></gf-premium-indicator> ></gf-premium-indicator>
</div> </div>
</td> </td>

View File

@ -159,10 +159,12 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
width: 1
},
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: 1,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`, color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false display: false
}, },
@ -173,17 +175,19 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
}, },
y: { y: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
display: false
},
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`, color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false, display: false
drawBorder: false
}, },
position: 'right', position: 'right',
ticks: { ticks: {
callback: (value: number) => { callback: (value: number) => {
return `${value} %`; return `${value.toFixed(2)} %`;
}, },
display: true, display: true,
mirror: true, mirror: true,

View File

@ -1,9 +1,9 @@
<mat-toolbar class="px-2"> <mat-toolbar class="px-2">
<ng-container *ngIf="user"> <ng-container *ngIf="user">
<a <a
[routerLink]="['/']"
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0" class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
mat-button mat-button
[routerLink]="['/']"
> >
<gf-logo></gf-logo> <gf-logo></gf-logo>
</a> </a>
@ -289,7 +289,7 @@
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
<a <a
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
mat-flat-button mat-flat-button

View File

@ -38,6 +38,7 @@ export class HeaderComponent implements OnChanges {
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string; public impersonationId: string;
public isMenuOpen: boolean; public isMenuOpen: boolean;
@ -79,6 +80,11 @@ export class HeaderComponent implements OnChanges {
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.hasPermissionToCreateUser = hasPermission(
this.info?.globalPermissions,
permissions.createUserAccount
);
} }
public impersonateAccount(aId: string) { public impersonateAccount(aId: string) {

View File

@ -27,7 +27,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public historicalDataItems: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;
public isLoading = true; public isLoading = true;
public readonly numberOfDays = 180; public readonly numberOfDays = 365;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

View File

@ -48,6 +48,7 @@ import { last } from 'lodash';
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: InvestmentItem[] = []; @Input() benchmarkDataItems: InvestmentItem[] = [];
@Input() benchmarkDataLabel = '';
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() currency: string; @Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@ -153,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
y: this.isInPercent ? investment * 100 : investment y: this.isInPercent ? investment * 100 : investment
}; };
}), }),
label: $localize`Deposit`, label: this.benchmarkDataLabel,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context: unknown) =>
this.isInFuture( this.isInFuture(
@ -194,6 +195,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart.options.plugins.tooltip = <unknown>( this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration() this.getTooltipPluginConfiguration()
); );
this.chart.options.scales.x.min = this.daysInMarket
? subDays(new Date(), this.daysInMarket).toISOString()
: undefined;
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -257,13 +261,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
width: this.groupBy ? 0 : 1
},
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`, color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false display: false
}, },
min: this.daysInMarket
? subDays(new Date(), this.daysInMarket).toISOString()
: undefined,
suggestedMax: new Date().toISOString(),
type: 'time', type: 'time',
time: { time: {
tooltipFormat: getDateFormatString(this.locale), tooltipFormat: getDateFormatString(this.locale),
@ -271,12 +281,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
}, },
y: { y: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
display: false
},
display: !this.isInPercent, display: !this.isInPercent,
grid: { grid: {
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`, color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false, display: false
drawBorder: false
}, },
position: 'right', position: 'right',
ticks: { ticks: {

View File

@ -223,7 +223,7 @@
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
[showActions]="false" [showActions]="false"
[showSymbolColumn]="false" [showNameColumn]="false"
(export)="onExport()" (export)="onExport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -3,12 +3,12 @@
<a <a
class="d-flex p-3 w-100" class="d-flex p-3 w-100"
[ngClass]="{ 'cursor-default': isLoading }" [ngClass]="{ 'cursor-default': isLoading }"
[routerLink]="[]"
[queryParams]="{ [queryParams]="{
dataSource: position?.dataSource, dataSource: position?.dataSource,
positionDetailDialog: true, positionDetailDialog: true,
symbol: position?.symbol symbol: position?.symbol
}" }"
[routerLink]="[]"
> >
<div class="d-flex mr-2"> <div class="d-flex mr-2">
<gf-trend-indicator <gf-trend-indicator
@ -39,7 +39,7 @@
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate"> <div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
<div class="h6 m-0 text-truncate">{{ position?.name }}</div> <div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex"> <div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span> <small class="text-muted">{{ position?.symbol | gfSymbol }}</small>
</div> </div>
<div class="d-flex mt-1"> <div class="d-flex mt-1">
<gf-value <gf-value

View File

@ -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>

View File

@ -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));
}
}

View File

@ -1,6 +1,7 @@
<img <img
*ngIf="url" *ngIf="src"
src="https://www.google.com/s2/favicons?domain={{ url }}&sz=64" onerror="this.style.display='none'"
[ngClass]="{ large: size === 'large' }" [ngClass]="{ large: size === 'large' }"
[src]="src"
[title]="tooltip ? tooltip : ''" [title]="tooltip ? tooltip : ''"
/> />

View File

@ -2,8 +2,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input, Input,
OnInit OnChanges
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client';
@Component({ @Component({
selector: 'gf-symbol-icon', selector: 'gf-symbol-icon',
@ -11,12 +12,22 @@ import {
templateUrl: './symbol-icon.component.html', templateUrl: './symbol-icon.component.html',
styleUrls: ['./symbol-icon.component.scss'] styleUrls: ['./symbol-icon.component.scss']
}) })
export class SymbolIconComponent implements OnInit { export class SymbolIconComponent implements OnChanges {
@Input() dataSource: DataSource;
@Input() size: 'large'; @Input() size: 'large';
@Input() symbol: string;
@Input() tooltip: string; @Input() tooltip: string;
@Input() url: string; @Input() url: string;
public src: string;
public constructor() {} 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}`;
}
}
} }

View File

@ -116,7 +116,6 @@
<div class="align-items-center d-flex mb-2"> <div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Language</div> <div i18n>Language</div>
<div class="hint-text text-muted" i18n>Beta</div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-form-field <mat-form-field
@ -132,9 +131,18 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
<mat-option value="es">Español</mat-option> <mat-option value="es"
<mat-option value="it">Italiano</mat-option> >Español (<ng-container i18n>Community</ng-container
<mat-option value="nl">Nederlands</mat-option> >)</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-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -1,5 +1,4 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -8,14 +7,5 @@ import { DataService } from '@ghostfolio/client/services/data.service';
templateUrl: './black-friday-2022-page.html' templateUrl: './black-friday-2022-page.html'
}) })
export class BlackFriday2022PageComponent { export class BlackFriday2022PageComponent {
public discount: number; public constructor() {}
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;
}
} }

View File

@ -14,7 +14,7 @@
</div> </div>
<section class="mb-4"> <section class="mb-4">
<p> <p>
Get {{ discount | percent }} off on our Get 75% off on our
<strong>Ghostfolio Premium</strong> <strong>Ghostfolio Premium</strong>
<gf-premium-indicator <gf-premium-indicator
class="d-inline-block ml-1" class="d-inline-block ml-1"

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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, its 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>

View File

@ -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 {}

View File

@ -2,15 +2,41 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3> <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 *ngIf="hasPermissionForSubscription" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/11/black-friday-2022" 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="h6 m-0 text-truncate">Black Friday 2022</div>
<div class="d-flex text-muted">2022-11-13</div> <div class="d-flex text-muted">2022-11-13</div>
</div> </div>
@ -31,10 +57,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/10/hacktoberfest-2022" 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="h6 m-0 text-truncate">Hacktoberfest 2022</div>
<div class="d-flex text-muted">2022-10-01</div> <div class="d-flex text-muted">2022-10-01</div>
</div> </div>
@ -55,10 +81,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/08/500-stars-on-github" 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="h6 m-0 text-truncate">500 Stars on GitHub</div>
<div class="d-flex text-muted">2022-08-18</div> <div class="d-flex text-muted">2022-08-18</div>
</div> </div>
@ -79,10 +105,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/07/ghostfolio-meets-internet-identity" 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"> <div class="h6 m-0 text-truncate">
Ghostfolio meets Internet Identity Ghostfolio meets Internet Identity
</div> </div>
@ -105,10 +131,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <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" 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"> <div class="h6 m-0 text-truncate">
How do I get my finances in order? How do I get my finances in order?
</div> </div>
@ -131,10 +157,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/01/ghostfolio-first-months-in-open-source" 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"> <div class="h6 m-0 text-truncate">
First months in Open Source First months in Open Source
</div> </div>
@ -157,10 +183,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2021/07/hello-ghostfolio" 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="h6 m-0 text-truncate">Hello Ghostfolio</div>
<div class="d-flex text-muted">2021-07-31</div> <div class="d-flex text-muted">2021-07-31</div>
</div> </div>
@ -181,10 +207,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex overflow-hidden w-100"
href="../de/blog/2021/07/hallo-ghostfolio" 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="h6 m-0 text-truncate">Hallo Ghostfolio</div>
<div class="d-flex text-muted">2021-07-31</div> <div class="d-flex text-muted">2021-07-31</div>
</div> </div>

View File

@ -17,6 +17,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string; public demoAuthToken: string;
public deviceType: string; public deviceType: string;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionToCreateUser: boolean;
public statistics: Statistics; public statistics: Statistics;
public testimonials = [ public testimonials = [
{ {
@ -54,6 +55,11 @@ export class LandingPageComponent implements OnDestroy, OnInit {
permissions.enableStatistics permissions.enableStatistics
); );
this.hasPermissionToCreateUser = hasPermission(
globalPermissions,
permissions.createUserAccount
);
this.statistics = statistics; this.statistics = statistics;
} }

View File

@ -31,15 +31,18 @@
<div class="button-container mb-5 row"> <div class="button-container mb-5 row">
<div class="align-items-center col d-flex justify-content-center"> <div class="align-items-center col d-flex justify-content-center">
<div class="text-center"> <div class="text-center">
<a <ng-container *ngIf="hasPermissionToCreateUser">
class="d-inline-block" <a
color="primary" class="d-inline-block"
mat-flat-button color="primary"
[routerLink]="['/register']" 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']"> <a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
Live Demo Live Demo
</a> </a>
@ -306,7 +309,7 @@
</div> </div>
</div> </div>
<div class="row my-5"> <div *ngIf="hasPermissionToCreateUser" class="row my-5">
<div class="col"> <div class="col">
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2> <h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
<p class="lead mb-3 text-center"> <p class="lead mb-3 text-center">

View File

@ -1,7 +1,9 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy,
ViewChild 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 { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n'; 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 { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
map,
startWith, startWith,
switchMap, switchMap,
takeUntil takeUntil
@ -38,7 +41,8 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-activity-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class CreateOrUpdateActivityDialog implements OnDestroy { export class CreateOrUpdateActivityDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('symbolAutocomplete') symbolAutocomplete;
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass).map((assetClass) => { public assetClasses = Object.keys(AssetClass).map((assetClass) => {
@ -51,8 +55,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public filteredTagsObservable: Observable<Tag[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = [];
public total = 0; public total = 0;
public Validators = Validators; public Validators = Validators;
@ -72,10 +79,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms, tags } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.platforms = platforms; this.platforms = platforms;
this.tags = tags;
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required], accountId: [this.data.activity?.accountId, Validators.required],
@ -86,12 +94,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
], ],
currencyOfFee: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [ dataSource: [
this.data.activity?.SymbolProfile?.dataSource, this.data.activity?.SymbolProfile?.dataSource,
Validators.required Validators.required
], ],
date: [this.data.activity?.date, Validators.required], date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required], fee: [this.data.activity?.fee, Validators.required],
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required], name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required], quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [ searchSymbol: [
@ -108,7 +121,36 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.valueChanges this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .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 ( if (
this.activityForm.controls['type'].value === 'BUY' || this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM' this.activityForm.controls['type'].value === 'ITEM'
@ -123,6 +165,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['unitPrice'].value - this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0; this.activityForm.controls['fee'].value ?? 0;
} }
this.changeDetectorRef.markForCheck();
}); });
this.filteredLookupItemsObservable = this.activityForm.controls[ 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 this.activityForm.controls['type'].valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((type: Type) => { .subscribe((type: Type) => {
@ -160,6 +213,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currency'].setValue( this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency this.data.user.settings.baseCurrency
); );
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
@ -189,6 +245,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
); );
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} }
this.changeDetectorRef.markForCheck();
}); });
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -223,6 +281,16 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
return aLookupItem?.symbol ?? ''; 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() { public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => { const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return ( return (
@ -242,10 +310,18 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
public onCancel(): void { public onCancel() {
this.dialogRef.close(); 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() { public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value, accountId: this.activityForm.controls['accountId'].value,
@ -286,6 +362,16 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.unsubscribeSubject.complete(); 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) { private updateSymbol(symbol: string) {
this.isLoading = true; this.isLoading = true;
@ -313,6 +399,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
this.activityForm.controls['currency'].setValue(currency); this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource); this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;

View File

@ -11,10 +11,10 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option i18n value="BUY">BUY</mat-option> <mat-option i18n value="BUY">Buy</mat-option>
<mat-option i18n value="DIVIDEND">DIVIDEND</mat-option> <mat-option i18n value="DIVIDEND">Dividend</mat-option>
<mat-option i18n value="ITEM">ITEM</mat-option> <mat-option i18n value="ITEM">Item</mat-option>
<mat-option i18n value="SELL">SELL</mat-option> <mat-option i18n value="SELL">Sell</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -41,11 +41,11 @@
autocorrect="off" autocorrect="off"
formControlName="searchSymbol" formControlName="searchSymbol"
matInput matInput
[matAutocomplete]="autocomplete" [matAutocomplete]="symbolAutocomplete"
(blur)="onBlurSymbol()" (blur)="onBlurSymbol()"
/> />
<mat-autocomplete <mat-autocomplete
#autocomplete="matAutocomplete" #symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn" [displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)" (optionSelected)="onUpdateSymbol($event)"
> >
@ -109,7 +109,15 @@
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <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" /> <input formControlName="unitPrice" matInput type="number" />
<span class="ml-2" matSuffix <span class="ml-2" matSuffix
>{{ activityForm.controls['currency'].value }}</span >{{ activityForm.controls['currency'].value }}</span
@ -127,6 +135,23 @@
</mat-form-field> </mat-form-field>
</div> </div>
<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-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" /> <input formControlName="fee" matInput type="number" />
@ -177,16 +202,38 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div <div [ngClass]="{ 'd-none': tags?.length <= 0 }">
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>
<mat-chip-list> <mat-chip-list #tagsChipList>
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value"> <mat-chip
*ngFor="let tag of activityForm.controls['tags']?.value"
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }} {{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip> </mat-chip>
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-list> </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> </mat-form-field>
</div> </div>
</div> </div>

View File

@ -19,9 +19,9 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
declarations: [CreateOrUpdateActivityDialog], declarations: [CreateOrUpdateActivityDialog],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfSymbolModule, GfSymbolModule,
GfValueModule, GfValueModule,
FormsModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,

View File

@ -20,6 +20,11 @@
} }
} }
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
.mat-form-field-appearance-outline { .mat-form-field-appearance-outline {
::ng-deep { ::ng-deep {
.mat-form-field-suffix { .mat-form-field-suffix {

View File

@ -7,6 +7,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; 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 { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { isArray } from 'lodash'; import { isArray } from 'lodash';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -20,8 +21,11 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
templateUrl: 'import-activities-dialog.html' templateUrl: 'import-activities-dialog.html'
}) })
export class ImportActivitiesDialog implements OnDestroy { export class ImportActivitiesDialog implements OnDestroy {
public activities: Activity[] = [];
public details: any[] = []; public details: any[] = [];
public errorMessages: string[] = []; public errorMessages: string[] = [];
public isFileSelected = false;
public selectedActivities: Activity[] = [];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -39,13 +43,47 @@ export class ImportActivitiesDialog implements OnDestroy {
this.dialogRef.close(); 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'); const input = document.createElement('input');
input.accept = 'application/JSON, .csv'; input.accept = 'application/JSON, .csv';
input.type = 'file'; input.type = 'file';
input.onchange = (event) => { input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Importing data...`); this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Getting the file reference // Getting the file reference
const file = (event.target as HTMLInputElement).files[0]; const file = (event.target as HTMLInputElement).files[0];
@ -80,11 +118,10 @@ export class ImportActivitiesDialog implements OnDestroy {
} }
try { try {
await this.importActivitiesService.importJson({ this.activities = await this.importActivitiesService.importJson({
content: content.activities content: content.activities,
isDryRun: true
}); });
this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ error, activities: content.activities }); this.handleImportError({ error, activities: content.activities });
@ -93,12 +130,11 @@ export class ImportActivitiesDialog implements OnDestroy {
return; return;
} else if (file.name.endsWith('.csv')) { } else if (file.name.endsWith('.csv')) {
try { try {
await this.importActivitiesService.importCsv({ this.activities = await this.importActivitiesService.importCsv({
fileContent, fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts userAccounts: this.data.user.accounts
}); });
this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({
@ -119,6 +155,10 @@ export class ImportActivitiesDialog implements OnDestroy {
activities: [], activities: [],
error: { error: { message: ['Unexpected format'] } } 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(); input.click();
} }
public onReset() { public updateSelection(data: Activity[]) {
this.details = []; this.selectedActivities = data;
this.errorMessages = [];
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -143,8 +182,6 @@ export class ImportActivitiesDialog implements OnDestroy {
activities: any[]; activities: any[];
error: any; error: any;
}) { }) {
this.snackBar.dismiss();
this.errorMessages = error?.error?.message; this.errorMessages = error?.error?.message;
for (const message of this.errorMessages) { for (const message of this.errorMessages) {
@ -161,16 +198,4 @@ export class ImportActivitiesDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
private handleImportSuccess() {
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
undefined,
{
duration: 3000
}
);
this.dialogRef.close();
}
} }

View File

@ -6,13 +6,13 @@
></gf-dialog-header> ></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content> <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"> <div class="d-flex justify-content-center flex-column">
<button <button
class="py-3" class="py-3"
color="primary" color="primary"
mat-stroked-button mat-stroked-button
(click)="onImport()" (click)="onSelectFile()"
> >
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span> <span i18n>Choose File</span>
@ -33,37 +33,68 @@
</p> </p>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="errorMessages.length > 0"> <ng-container *ngIf="isFileSelected">
<mat-accordion displayMode="flat"> <ng-container *ngIf="errorMessages.length === 0; else errorMessage">
<mat-expansion-panel <gf-activities-table
*ngFor="let message of errorMessages; let i = index" [activities]="activities"
[disabled]="!details[i]" [baseCurrency]="data?.user?.settings?.baseCurrency"
> [deviceType]="data?.deviceType"
<mat-expansion-panel-header class="pl-1"> [hasPermissionToCreateActivity]="false"
<mat-panel-title> [hasPermissionToExportActivities]="false"
<div class="d-flex"> [hasPermissionToFilter]="false"
<div class="align-items-center d-flex mr-2"> [hasPermissionToImportActivities]="false"
<ion-icon name="warning-outline"></ion-icon> [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>
<div>{{ message }}</div> </mat-panel-title>
</div> </mat-expansion-panel-header>
</mat-panel-title> <pre
</mat-expansion-panel-header> *ngIf="details[i]"
<pre class="m-0"
*ngIf="details[i]" ><code>{{ details[i] | json }}</code></pre>
class="m-0" </mat-expansion-panel>
><code>{{ details[i] | json }}</code></pre> </mat-accordion>
</mat-expansion-panel> <div class="mt-2">
</mat-accordion> <button mat-button (click)="onReset()">
<div class="mt-2"> <ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<button mat-button (click)="onReset()"> <span i18n>Back</span>
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon> </button>
<span i18n>Back</span> </div>
</button> </ng-template>
</div>
</ng-container> </ng-container>
</div> </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 <gf-dialog-footer
mat-dialog-actions mat-dialog-actions
[deviceType]="data.deviceType" [deviceType]="data.deviceType"

View File

@ -5,6 +5,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion'; import { MatExpansionModule } from '@angular/material/expansion';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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'; import { ImportActivitiesDialog } from './import-activities-dialog.component';
@ -12,6 +13,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
declarations: [ImportActivitiesDialog], declarations: [ImportActivitiesDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
MatButtonModule, MatButtonModule,

View File

@ -22,7 +22,7 @@ import { Market, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
@ -71,7 +71,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
| 'value' | 'value'
>; >;
}; };
public routeQueryParams: Subscription;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
@ -98,7 +97,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) { if (params['accountId'] && params['accountDetailDialog']) {

View File

@ -1,4 +1,8 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; 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 { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -9,8 +13,9 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; 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 { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -30,9 +35,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
public dividendsByMonth: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
public firstOrderDate: Date; public firstOrderDate: Date;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Deposit`;
public investmentsByMonth: InvestmentItem[]; public investmentsByMonth: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean; public isLoadingBenchmarkComparator: boolean;
public isLoadingInvestmentChart: boolean; public isLoadingInvestmentChart: boolean;
@ -42,6 +50,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
]; ];
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Deposit`;
public top3: Position[]; public top3: Position[];
public user: User; public user: User;
@ -50,12 +59,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private dialog: MatDialog,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; 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() { public ngOnInit() {
@ -124,6 +151,47 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); 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() { private update() {
this.isLoadingBenchmarkComparator = true; this.isLoadingBenchmarkComparator = true;
this.isLoadingInvestmentChart = true; this.isLoadingInvestmentChart = true;
@ -165,6 +233,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); 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 this.dataService
.fetchInvestments({ .fetchInvestments({
groupBy: 'month', groupBy: 'month',

View File

@ -35,20 +35,30 @@
> >
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div *ngFor="let position of top3; let i = index" class="d-flex py-1"> <div *ngFor="let position of top3; let i = index" class="py-1">
<div class="flex-grow-1 mr-2 text-truncate"> <a
{{ i + 1 }}. {{ position.name }} class="d-flex"
</div> [queryParams]="{
<div class="d-flex justify-content-end"> dataSource: position.dataSource,
<gf-value positionDetailDialog: true,
class="justify-content-end" symbol: position.symbol
position="end" }"
[colorizeSign]="true" [routerLink]="[]"
[isPercent]="true" >
[locale]="user?.settings?.locale" <div class="flex-grow-1 mr-2 text-truncate">
[value]="position.netPerformancePercentage" {{ i + 1 }}. {{ position.name }}
></gf-value> </div>
</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>
<div> <div>
<ngx-skeleton-loader <ngx-skeleton-loader
@ -71,23 +81,30 @@
> >
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div <div *ngFor="let position of bottom3; let i = index" class="py-1">
*ngFor="let position of bottom3; let i = index" <a
class="d-flex py-1" class="d-flex"
> [queryParams]="{
<div class="flex-grow-1 mr-2 text-truncate"> dataSource: position.dataSource,
{{ i + 1 }}. {{ position.name }} positionDetailDialog: true,
</div> symbol: position.symbol
<div class="d-flex justify-content-end"> }"
<gf-value [routerLink]="[]"
class="justify-content-end" >
position="end" <div class="flex-grow-1 mr-2 text-truncate">
[colorizeSign]="true" {{ i + 1 }}. {{ position.name }}
[isPercent]="true" </div>
[locale]="user?.settings?.locale" <div class="d-flex justify-content-end">
[value]="position.netPerformancePercentage" <gf-value
></gf-value> class="justify-content-end"
</div> position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
</div>
</a>
</div> </div>
<div> <div>
<ngx-skeleton-loader <ngx-skeleton-loader
@ -121,6 +138,7 @@
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
[benchmarkDataItems]="investments" [benchmarkDataItems]="investments"
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
@ -133,7 +151,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<div class="align-items-center d-flex mb-4"> <div class="align-items-center d-flex mb-4">
<div <div
@ -158,6 +176,7 @@
class="h-100" class="h-100"
groupBy="month" groupBy="month"
[benchmarkDataItems]="investmentsByMonth" [benchmarkDataItems]="investmentsByMonth"
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
@ -168,4 +187,40 @@
</div> </div>
</div> </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> </div>

View File

@ -16,7 +16,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource } from '@prisma/client'; import { AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
@ -36,7 +36,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
public placeholder = ''; public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
public positionsArray: PortfolioPosition[]; public positionsArray: PortfolioPosition[];
public routeQueryParams: Subscription;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -51,7 +50,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (

View File

@ -12,13 +12,13 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<gf-positions-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positionsArray" [positions]="positionsArray"
></gf-positions-table> ></gf-holdings-table>
<div <div
*ngIf="hasPermissionToCreateOrder && positionsArray?.length > 0" *ngIf="hasPermissionToCreateOrder && positionsArray?.length > 0"
class="text-center" class="text-center"

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; 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 { 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 { HoldingsPageRoutingModule } from './holdings-page-routing.module';
import { HoldingsPageComponent } from './holdings-page.component'; import { HoldingsPageComponent } from './holdings-page.component';
@ -12,7 +12,7 @@ import { HoldingsPageComponent } from './holdings-page.component';
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesFilterModule, GfActivitiesFilterModule,
GfPositionsTableModule, GfHoldingsTableModule,
HoldingsPageRoutingModule, HoldingsPageRoutingModule,
MatButtonModule MatButtonModule
], ],

View File

@ -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="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View File

@ -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 { .mat-card {
&:hover, &:hover,
&.active { &.active {
@ -36,8 +22,4 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.intro {
background-color: rgba(var(--light-dividers));
}
} }

View File

@ -115,12 +115,12 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<gf-positions-table <gf-holdings-table
pageSize="7" pageSize="7"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToShowValues]="false" [hasPermissionToShowValues]="false"
[positions]="positionsArray" [positions]="positionsArray"
></gf-positions-table> ></gf-holdings-table>
</div> </div>
</div> </div>
<div class="row my-5"> <div class="row my-5">

View File

@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; 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 { 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 { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -14,8 +14,8 @@ import { PublicPageComponent } from './public-page.component';
declarations: [PublicPageComponent], declarations: [PublicPageComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfHoldingsTableModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfPositionsTableModule,
GfValueModule, GfValueModule,
GfWorldMapChartModule, GfWorldMapChartModule,
MatButtonModule, MatButtonModule,

View File

@ -25,6 +25,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string; public demoAuthToken: string;
public deviceType: string; public deviceType: string;
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionToCreateUser: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public info: InfoItem; public info: InfoItem;
@ -52,6 +53,10 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
globalPermissions, globalPermissions,
permissions.enableSocialLogin permissions.enableSocialLogin
); );
this.hasPermissionToCreateUser = hasPermission(
globalPermissions,
permissions.createUserAccount
);
} }
public async createAccount() { public async createAccount() {

Some files were not shown because too many files have changed in this diff Show More