Compare commits
97 Commits
Author | SHA1 | Date | |
---|---|---|---|
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 | |||
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,15 +24,16 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -2,7 +2,7 @@ language: node_js
|
|||||||
git:
|
git:
|
||||||
depth: false
|
depth: false
|
||||||
node_js:
|
node_js:
|
||||||
- 14
|
- 16
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- docker
|
||||||
|
216
CHANGELOG.md
216
CHANGELOG.md
@ -5,6 +5,222 @@ 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.183.0 - 24.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a filter by asset sub class for the asset profiles in the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.182.0 - 23.08.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Extended and made the columns of the asset profiles sortable in the admin control
|
||||||
|
- Moved the asset profile details in the admin control panel to a dialog
|
||||||
|
|
||||||
|
## 1.181.2 - 21.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a language selector to the account page
|
||||||
|
- Added support for translated labels in the value component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Integrated the commands `database:setup` and `database:migrate` into the container start
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a division by zero error in the benchmarks calculation
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply manual data migration (`yarn database:migrate`) is not needed anymore
|
||||||
|
|
||||||
|
## 1.180.1 - 18.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
|
||||||
|
- Set up language localization for German (`de`)
|
||||||
|
- Resolved the feature graphic of the blog post
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Tagged template literal strings in components for localization with `$localize`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the license component in the about page
|
||||||
|
- Fixed the links to the blog posts
|
||||||
|
|
||||||
|
## 1.179.5 - 15.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up i18n support
|
||||||
|
- Added a blog post: _500 Stars on GitHub_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reduced the maximum width of the performance chart on the home page
|
||||||
|
|
||||||
|
## 1.178.0 - 09.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `url` to the symbol profile overrides model for manual adjustments
|
||||||
|
- Added default values for `countries` and `sectors` of the symbol profile overrides model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the initialization of the exchange rate service
|
||||||
|
- Improved the orders query for `assetClass` with symbol profile overrides
|
||||||
|
- Improved the styling of the benchmarks in the markets overview
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.177.0 - 04.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
|
||||||
|
- Added the `AGPLv3` logo to the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the initialization of the exchange rate service
|
||||||
|
- Upgraded `angular` from version `14.0.2` to `14.1.0`
|
||||||
|
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
|
||||||
|
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
|
||||||
|
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handled database connection errors (do not exit process)
|
||||||
|
|
||||||
|
## 1.176.2 - 31.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added page titles
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
|
||||||
|
- Changed the log level settings
|
||||||
|
- Refactored the access of the environment variables in the bootstrap function (api)
|
||||||
|
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Upgrade to `Node.js` 16+
|
||||||
|
|
||||||
|
## 1.175.0 - 29.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up a Frequently Asked Questions (FAQ) page
|
||||||
|
- Added the savings rate to the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added the symbols to the activities in the account detail dialog
|
||||||
|
|
||||||
|
## 1.174.0 - 27.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support a note for activities
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.173.0 - 23.07.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
|
||||||
|
|
||||||
|
## 1.172.0 - 23.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio meets Internet Identity_
|
||||||
|
|
||||||
|
## 1.171.0 - 22.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _Internet Identity_ as a new social login provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the empty state of the
|
||||||
|
- _Analysis_ section
|
||||||
|
- _Holdings_ section
|
||||||
|
- performance chart on the home page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the distorted tooltip in the performance chart on the home page
|
||||||
|
- Fixed a calculation issue of the current month in the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.170.0 - 19.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the tags in the create or edit transaction dialog
|
||||||
|
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
|
||||||
|
- Removed the activities import limit for users with a subscription
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
|
||||||
|
|
||||||
|
## 1.169.0 - 14.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
|
||||||
|
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the cryptocurrencies list to support more coins by default
|
||||||
|
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
|
||||||
|
|
||||||
|
## 1.168.0 - 10.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the content height of the account detail dialog
|
||||||
|
|
||||||
## 1.167.0 - 07.07.2022
|
## 1.167.0 - 07.07.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM node:14-alpine as builder
|
FROM node:16-alpine as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
|
|
||||||
@ -45,8 +45,8 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:14-alpine
|
FROM node:16-alpine
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE 3333
|
||||||
CMD [ "node", "main" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
39
README.md
39
README.md
@ -12,7 +12,7 @@
|
|||||||
<strong>Open Source Wealth Management Software</strong>
|
<strong>Open Source Wealth Management Software</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/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/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/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="#contributing">
|
<a href="#contributing">
|
||||||
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
|
### Supported Environment Variables
|
||||||
|
|
||||||
|
| Name | Default Value | Description |
|
||||||
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
|
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
||||||
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
|
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
@ -97,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
@ -114,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
@ -134,7 +135,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
|
50
angular.json
50
angular.json
@ -77,41 +77,45 @@
|
|||||||
"polyfills": "apps/client/src/polyfills.ts",
|
"polyfills": "apps/client/src/polyfills.ts",
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"apps/client/src/assets",
|
|
||||||
{
|
{
|
||||||
"glob": "assetlinks.json",
|
"glob": "assetlinks.json",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./.well-known"
|
"output": "./../.well-known"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "robots.txt",
|
"glob": "robots.txt",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "sitemap.xml",
|
"glob": "sitemap.xml",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
"input": "node_modules/ionicons/dist/ionicons",
|
||||||
"output": "./ionicons"
|
"output": "./../ionicons"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*.js",
|
"glob": "**/*.js",
|
||||||
"input": "node_modules/ionicons/dist/",
|
"input": "node_modules/ionicons/dist/",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
|
"output": "./../assets/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
"styles": ["apps/client/src/styles.scss"],
|
||||||
@ -124,6 +128,14 @@
|
|||||||
"namedChunks": true
|
"namedChunks": true
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"localize": ["de"]
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"baseHref": "/en/",
|
||||||
|
"localize": ["en"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -162,15 +174,24 @@
|
|||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
"proxyConfig": "apps/client/proxy.conf.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"browserTarget": "client:build:development-de"
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"browserTarget": "client:build:development-en"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build"
|
"browserTarget": "client:build",
|
||||||
|
"includeContext": true,
|
||||||
|
"outputPath": "src/locales",
|
||||||
|
"targetFiles": ["messages.de.xlf"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
@ -188,6 +209,15 @@
|
|||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"i18n": {
|
||||||
|
"locales": {
|
||||||
|
"de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceLocale": "en"
|
||||||
|
},
|
||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
|
@ -8,7 +8,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} 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';
|
||||||
@ -22,6 +23,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -226,7 +228,9 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(
|
||||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||||
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -239,7 +243,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketData();
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...assetSubClasses.map((assetSubClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetSubClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.adminService.getMarketData(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
@ -12,11 +11,13 @@ import {
|
|||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@ -24,7 +25,6 @@ export class AdminService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -65,14 +65,27 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||||
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||||
this.exchangeRateDataService
|
|
||||||
|
if (filtersByAssetSubClass) {
|
||||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
|
} else {
|
||||||
|
currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
@ -86,17 +99,24 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol
|
symbol,
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
|
assetClass: true,
|
||||||
|
assetSubClass: true,
|
||||||
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -104,10 +124,14 @@ export class AdminService {
|
|||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
|
const countriesCount = symbolProfile.countries
|
||||||
|
? Object.keys(symbolProfile.countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketData.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -115,10 +139,17 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = symbolProfile.sectors
|
||||||
|
? Object.keys(symbolProfile.sectors).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
activityCount: symbolProfile._count.Order,
|
||||||
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -174,7 +205,6 @@ export class AdminService {
|
|||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
},
|
},
|
||||||
alias: true,
|
|
||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
@ -194,7 +224,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
@ -206,7 +236,6 @@ export class AdminService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize() {
|
||||||
|
try {
|
||||||
|
await this.exchangeRateDataService.initialize();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
@ -23,6 +23,7 @@ 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 { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
|
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 { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
@ -82,4 +83,10 @@ import { UserModule } from './user/user.module';
|
|||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply(FrontendMiddleware)
|
||||||
|
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
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 { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -31,7 +33,9 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
public async accessTokenLogin(
|
||||||
|
@Param('accessToken') accessToken: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
accessToken
|
||||||
@ -59,9 +63,34 @@ export class AuthController {
|
|||||||
const jwt: string = req.user.jwt;
|
const jwt: string = req.user.jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('internet-identity/:principalId')
|
||||||
|
public async internetIdentityLogin(
|
||||||
|
@Param('principalId') principalId: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
|
principalId
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ 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 { 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 { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ export class AuthService {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async validateAnonymousLogin(accessToken: string) {
|
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const hashedAccessToken = this.userService.createAccessToken(
|
const hashedAccessToken = this.userService.createAccessToken(
|
||||||
@ -26,7 +27,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const jwt: string = this.jwtService.sign({
|
const jwt = this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +41,33 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async validateInternetIdentityLogin(principalId: string) {
|
||||||
|
try {
|
||||||
|
const provider: Provider = 'INTERNET_IDENTITY';
|
||||||
|
|
||||||
|
let [user] = await this.userService.users({
|
||||||
|
where: { provider, thirdPartyId: principalId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user if not found
|
||||||
|
user = await this.userService.createUser({
|
||||||
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'validateInternetIdentityLogin',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async validateOAuthLogin({
|
public async validateOAuthLogin({
|
||||||
provider,
|
provider,
|
||||||
thirdPartyId
|
thirdPartyId
|
||||||
@ -57,13 +85,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt: string = this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
return jwt;
|
throw new InternalServerErrorException(
|
||||||
} catch (err) {
|
'validateOAuthLogin',
|
||||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
error.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,9 +48,13 @@ export class BenchmarkService {
|
|||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
||||||
|
|
||||||
const performancePercentFromAllTimeHigh = new Big(marketPrice)
|
let performancePercentFromAllTimeHigh = new Big(0);
|
||||||
|
|
||||||
|
if (allTimeHigh) {
|
||||||
|
performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||||
.div(allTimeHigh)
|
.div(allTimeHigh)
|
||||||
.minus(1);
|
.minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
|
@ -18,6 +18,7 @@ export class ExportService {
|
|||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
accountId: true,
|
accountId: true,
|
||||||
|
comment: true,
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
id: true,
|
id: true,
|
||||||
@ -40,6 +41,7 @@ export class ExportService {
|
|||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
id,
|
id,
|
||||||
@ -50,6 +52,7 @@ export class ExportService {
|
|||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
fee,
|
fee,
|
||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
|
81
apps/api/src/app/frontend.middleware.ts
Normal file
81
apps/api/src/app/frontend.middleware.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
|
public indexHtmlDe = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
public indexHtmlEn = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
||||||
|
// Skip
|
||||||
|
next();
|
||||||
|
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'de',
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||||
|
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolate(template: string, context: any) {
|
||||||
|
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||||
|
const properties = objectPath.split('.');
|
||||||
|
return properties.reduce(
|
||||||
|
(previous, current) => previous?.[current],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileRequest(filename: string) {
|
||||||
|
if (filename === '/assets/LICENSE') {
|
||||||
|
return true;
|
||||||
|
} else if (filename.includes('auth/ey')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename.split('.').pop() !== filename;
|
||||||
|
}
|
||||||
|
}
|
@ -34,8 +34,20 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxActivitiesToImport = this.configurationService.get(
|
||||||
|
'MAX_ACTIVITIES_TO_IMPORT'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Premium'
|
||||||
|
) {
|
||||||
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
return await this.importService.import({
|
||||||
|
maxActivitiesToImport,
|
||||||
activities: importData.activities,
|
activities: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
@ -17,9 +17,11 @@ export class ImportService {
|
|||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activities,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
for (const activity of activities) {
|
for (const activity of activities) {
|
||||||
@ -32,7 +34,11 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({ activities, userId });
|
await this.validateActivities({
|
||||||
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
(account) => {
|
||||||
@ -42,6 +48,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -52,6 +59,7 @@ export class ImportService {
|
|||||||
unitPrice
|
unitPrice
|
||||||
} of activities) {
|
} of activities) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
|
comment,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
@ -81,19 +89,15 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activities,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (activities?.length > maxActivitiesToImport) {
|
||||||
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Too many activities (${this.configurationService.get(
|
|
||||||
'MAX_ORDERS_TO_IMPORT'
|
|
||||||
)} at most)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
|
@ -1,30 +1,46 @@
|
|||||||
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|
||||||
@IsEnum(AssetClass, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
@IsEnum(AssetSubClass, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@ -39,6 +55,10 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsEnum(Type, { each: true })
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
Tag,
|
||||||
Type as TypeOfOrder
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -71,6 +72,7 @@ export class OrderService {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
@ -80,6 +82,8 @@ export class OrderService {
|
|||||||
return account.isDefault === true;
|
return account.isDefault === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let Account = {
|
let Account = {
|
||||||
connect: {
|
connect: {
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -139,9 +143,15 @@ export class OrderService {
|
|||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
delete data.comment;
|
||||||
|
}
|
||||||
|
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
@ -150,7 +160,12 @@ export class OrderService {
|
|||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -215,9 +230,10 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SymbolProfileOverrides: {
|
OR: [
|
||||||
is: null
|
{ SymbolProfileOverrides: { is: null } },
|
||||||
}
|
{ SymbolProfileOverrides: { assetClass: null } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -298,6 +314,7 @@ export class OrderService {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
@ -305,6 +322,12 @@ export class OrderService {
|
|||||||
delete data.Account;
|
delete data.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
data.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
@ -331,11 +354,17 @@ export class OrderService {
|
|||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -20,6 +29,13 @@ export class UpdateOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@ -41,6 +57,10 @@ export class UpdateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('0')
|
totalInvestment: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2021-11-30')
|
parseDate('2021-11-30')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('273.2')
|
totalInvestment: new Big('273.2')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
positions: [],
|
positions: [],
|
||||||
totalInvestment: new Big(0)
|
totalInvestment: new Big(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2022-03-07')
|
parseDate('2022-03-07')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('75.80')
|
totalInvestment: new Big('75.80')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('75.8') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -14,8 +14,11 @@ import {
|
|||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isSameMonth,
|
||||||
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min
|
min,
|
||||||
|
set
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||||
|
|
||||||
@ -323,6 +326,53 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||||
|
if (this.orders.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const investments = [];
|
||||||
|
let currentDate: Date;
|
||||||
|
let investmentByMonth = new Big(0);
|
||||||
|
|
||||||
|
for (const [index, order] of this.orders.entries()) {
|
||||||
|
if (
|
||||||
|
isSameMonth(parseDate(order.date), currentDate) &&
|
||||||
|
isSameYear(parseDate(order.date), currentDate)
|
||||||
|
) {
|
||||||
|
// Same month: Add up investments
|
||||||
|
|
||||||
|
investmentByMonth = investmentByMonth.plus(
|
||||||
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// New month: Store previous month and reset
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
investments.push({
|
||||||
|
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||||
|
investment: investmentByMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = parseDate(order.date);
|
||||||
|
investmentByMonth = order.quantity
|
||||||
|
.mul(order.unitPrice)
|
||||||
|
.mul(this.getFactor(order.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === this.orders.length - 1) {
|
||||||
|
// Store current month (latest order)
|
||||||
|
investments.push({
|
||||||
|
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||||
|
investment: investmentByMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return investments;
|
||||||
|
}
|
||||||
|
|
||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
timelineSpecification: TimelineSpecification[],
|
timelineSpecification: TimelineSpecification[],
|
||||||
endDate: string
|
endDate: string
|
||||||
|
@ -20,7 +20,12 @@ import {
|
|||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -217,7 +222,8 @@ export class PortfolioController {
|
|||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
@ -229,9 +235,16 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
let investments: InvestmentItem[];
|
||||||
impersonationId
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId,
|
||||||
|
'month'
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
|
@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
|
|||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
Market,
|
Market,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -64,6 +65,7 @@ import {
|
|||||||
max,
|
max,
|
||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
|
set,
|
||||||
setDayOfYear,
|
setDayOfYear,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
subDays,
|
subDays,
|
||||||
@ -183,7 +185,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string,
|
||||||
|
groupBy?: GroupBy
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
@ -204,21 +207,49 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments().map((item) => {
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add investment of current month
|
||||||
|
const dateOfCurrentMonth = format(
|
||||||
|
set(new Date(), { date: 1 }),
|
||||||
|
DATE_FORMAT
|
||||||
|
);
|
||||||
|
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
||||||
|
return date === dateOfCurrentMonth;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (investmentOfCurrentMonth.length <= 0) {
|
||||||
|
investments.push({
|
||||||
|
date: dateOfCurrentMonth,
|
||||||
|
investment: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
investments = portfolioCalculator
|
||||||
|
.getInvestments()
|
||||||
|
.map(({ date, investment }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
investment: investment.toNumber()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Add investment of today
|
// Add investment of today
|
||||||
const investmentOfToday = investments.filter((investment) => {
|
const investmentOfToday = investments.filter(({ date }) => {
|
||||||
return investment.date === format(new Date(), DATE_FORMAT);
|
return date === format(new Date(), DATE_FORMAT);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (investmentOfToday.length <= 0) {
|
if (investmentOfToday.length <= 0) {
|
||||||
const pastInvestments = investments.filter((investment) => {
|
const pastInvestments = investments.filter(({ date }) => {
|
||||||
return isBefore(parseDate(investment.date), new Date());
|
return isBefore(parseDate(date), new Date());
|
||||||
});
|
});
|
||||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
@ -227,6 +258,7 @@ export class PortfolioService {
|
|||||||
investment: lastInvestment?.investment ?? 0
|
investment: lastInvestment?.investment ?? 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sortBy(investments, (investment) => {
|
return sortBy(investments, (investment) => {
|
||||||
return investment.date;
|
return investment.date;
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
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_COUPONS } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_COUPONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -93,7 +96,11 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
@ -33,7 +34,9 @@ export class SubscriptionService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
|
cancel_url: `${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: userId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
|
@ -9,6 +9,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
@ -36,14 +36,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
{
|
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||||
Account,
|
|
||||||
alias,
|
|
||||||
id,
|
|
||||||
permissions,
|
|
||||||
Settings,
|
|
||||||
subscription
|
|
||||||
}: UserWithSettings,
|
|
||||||
aLocale = locale
|
aLocale = locale
|
||||||
): Promise<IUser> {
|
): Promise<IUser> {
|
||||||
const access = await this.prismaService.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
@ -63,7 +56,6 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"LUNA1": "Terra",
|
"LUNA1": "Terra",
|
||||||
"UNI1": "Uniswap"
|
"LUNA2": "Terra",
|
||||||
|
"SGB1": "Songbird",
|
||||||
|
"UNI1": "Uniswap",
|
||||||
|
"UST": "TerraUSD"
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,24 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
const NODE_ENV =
|
||||||
|
configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||||
|
'development';
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger:
|
||||||
|
NODE_ENV === 'production'
|
||||||
|
? ['error', 'log', 'warn']
|
||||||
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
|
});
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
defaultVersion: '1',
|
defaultVersion: '1',
|
||||||
@ -20,11 +33,11 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const port = process.env.PORT || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
await app.listen(port, host, () => {
|
await app.listen(PORT, HOST, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://${host}:${port}`);
|
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,9 @@ export class ConfigurationService {
|
|||||||
BASE_CURRENCY: str({ default: 'USD' }),
|
BASE_CURRENCY: str({ default: 'USD' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
|
DATA_SOURCES: json({
|
||||||
|
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
|
||||||
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
@ -33,8 +35,8 @@ export class ConfigurationService {
|
|||||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
HOST: host({ default: '0.0.0.0' }),
|
HOST: host({ default: '0.0.0.0' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
|
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: host({ default: 'localhost' }),
|
REDIS_HOST: host({ default: 'localhost' }),
|
||||||
|
@ -168,6 +168,7 @@ export class DataProviderService {
|
|||||||
const response: {
|
const response: {
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
} = {};
|
} = {};
|
||||||
|
const startTimeTotal = performance.now();
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
|
|
||||||
@ -176,25 +177,59 @@ export class DataProviderService {
|
|||||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
itemsGroupedByDataSource
|
itemsGroupedByDataSource
|
||||||
)) {
|
)) {
|
||||||
|
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
return dataGatheringItem.symbol;
|
return dataGatheringItem.symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise = Promise.resolve(
|
const maximumNumberOfSymbolsPerRequest =
|
||||||
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||||
|
Number.MAX_SAFE_INTEGER;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < symbols.length;
|
||||||
|
i += maximumNumberOfSymbolsPerRequest
|
||||||
|
) {
|
||||||
|
const startTimeDataSource = performance.now();
|
||||||
|
|
||||||
|
const symbolsChunk = symbols.slice(
|
||||||
|
i,
|
||||||
|
i + maximumNumberOfSymbolsPerRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then((result) => {
|
promise.then((result) => {
|
||||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
for (const [symbol, dataProviderResponse] of Object.entries(
|
||||||
|
result
|
||||||
|
)) {
|
||||||
response[symbol] = dataProviderResponse;
|
response[symbol] = dataProviderResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
||||||
|
(performance.now() - startTimeDataSource) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
Logger.debug('------------------------------------------------');
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${items.length} quotes in ${(
|
||||||
|
(performance.now() - startTimeTotal) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
|
Logger.debug('================================================');
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
// It is not recommended using more than 15-20 tickers per request
|
||||||
|
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.EOD_HISTORICAL_DATA;
|
return DataSource.EOD_HISTORICAL_DATA;
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ export interface DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>; // TODO: Return only one symbol
|
}>; // TODO: Return only one symbol
|
||||||
|
|
||||||
|
getMaxNumberOfSymbolsPerRequest?(): number;
|
||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
getQuotes(
|
getQuotes(
|
||||||
|
@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
const symbol = aYahooFinanceSymbol.replace(
|
let symbol = aYahooFinanceSymbol.replace(
|
||||||
new RegExp(`-${this.baseCurrency}$`),
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||||
|
symbol = `${this.baseCurrency}${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
return symbol.replace('=X', '');
|
return symbol.replace('=X', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.YAHOO;
|
return DataSource.YAHOO;
|
||||||
}
|
}
|
||||||
@ -261,6 +270,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (yahooFinanceSymbols.includes('USDUSX=X')) {
|
||||||
|
// Convert USD to USX (cent)
|
||||||
|
response['USDUSX'] = {
|
||||||
|
currency: 'USX',
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: new Big(1).mul(100).toNumber(),
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'YahooFinanceService');
|
Logger.error(error, 'YahooFinanceService');
|
||||||
|
@ -22,9 +22,7 @@ export class ExchangeRateDataService {
|
|||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
) {
|
) {}
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrencies() {
|
public getCurrencies() {
|
||||||
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
||||||
@ -122,15 +120,6 @@ export class ExchangeRateDataService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
|
|
||||||
return isNaN(exchangeRate);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasNaN) {
|
|
||||||
// Reinitialize if data is not loaded correctly
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
let factor = 1;
|
let factor = 1;
|
||||||
|
|
||||||
if (aFromCurrency !== aToCurrency) {
|
if (aFromCurrency !== aToCurrency) {
|
||||||
|
@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
GOOGLE_SHEETS_ID: string;
|
GOOGLE_SHEETS_ID: string;
|
||||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||||
JWT_SECRET_KEY: string;
|
JWT_SECRET_KEY: string;
|
||||||
|
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
MAX_ORDERS_TO_IMPORT: number;
|
|
||||||
PORT: number;
|
PORT: number;
|
||||||
RAKUTEN_RAPID_API_KEY: string;
|
RAKUTEN_RAPID_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
extends PrismaClient
|
extends PrismaClient
|
||||||
implements OnModuleInit, OnModuleDestroy {
|
implements OnModuleInit, OnModuleDestroy
|
||||||
async onModuleInit() {
|
{
|
||||||
|
public async onModuleInit() {
|
||||||
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'PrismaService');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
public async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -115,9 +115,16 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||||
item.sectors =
|
|
||||||
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
if (
|
||||||
item.sectors;
|
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
item.sectors = item.SymbolProfileOverrides
|
||||||
|
.sectors as unknown as Sector[];
|
||||||
|
}
|
||||||
|
|
||||||
|
item.url = item.SymbolProfileOverrides?.url ?? item.url;
|
||||||
|
|
||||||
delete item.SymbolProfileOverrides;
|
delete item.SymbolProfileOverrides;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
|
||||||
|
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
|
||||||
|
|
||||||
import { ModulePreloadService } from './core/module-preload.service';
|
import { ModulePreloadService } from './core/module-preload.service';
|
||||||
|
|
||||||
@ -53,30 +54,56 @@ const routes: Routes = [
|
|||||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
path: 'blog/2021/07/hallo-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import(
|
import(
|
||||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||||
).then((m) => m.HalloGhostfolioPageModule)
|
).then((m) => m.HalloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2021/07/hello-ghostfolio',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||||
|
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
|
||||||
|
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
|
||||||
|
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/08/500-stars-on-github',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||||
|
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
|
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'en/blog/2021/07/hello-ghostfolio',
|
path: 'faq',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import(
|
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
|
||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
|
||||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'features',
|
path: 'features',
|
||||||
@ -215,7 +242,10 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
providers: [ModulePreloadService],
|
providers: [
|
||||||
|
ModulePreloadService,
|
||||||
|
{ provide: TitleStrategy, useClass: PageTitleStrategy }
|
||||||
|
],
|
||||||
exports: [RouterModule]
|
exports: [RouterModule]
|
||||||
})
|
})
|
||||||
export class AppRoutingModule {}
|
export class AppRoutingModule {}
|
||||||
|
@ -24,8 +24,8 @@
|
|||||||
class="cursor-pointer d-inline-block info-message px-3 py-2"
|
class="cursor-pointer d-inline-block info-message px-3 py-2"
|
||||||
(click)="onCreateAccount()"
|
(click)="onCreateAccount()"
|
||||||
>
|
>
|
||||||
<span i18n>You are using the Live Demo.</span>
|
<span>You are using the Live Demo.</span>
|
||||||
<span class="a ml-2" i18n>Create Account</span>
|
<span class="a ml-2">Create Account</span>
|
||||||
</div></a
|
</div></a
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -21,8 +21,10 @@
|
|||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||||
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
|
<a
|
||||||
>{{ baseUrl }}/p/{{ element.id }}</a
|
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
||||||
|
target="_blank"
|
||||||
|
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
||||||
>
|
>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</td>
|
</td>
|
||||||
@ -41,8 +43,8 @@
|
|||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
|
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
Revoke
|
<ng-container i18n>Revoke</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -24,6 +25,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public baseUrl = window.location.origin;
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
|
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
@ -44,7 +46,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public onDeleteAccess(aId: string) {
|
public onDeleteAccess(aId: string) {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
'Do you really want to revoke this granted access?'
|
$localize`Do you really want to revoke this granted access?`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,18 +21,10 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
|
||||||
label="Account Type"
|
|
||||||
size="medium"
|
|
||||||
[value]="accountType"
|
|
||||||
></gf-value>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value size="medium" [value]="platformName">Platform</gf-value>
|
||||||
label="Platform"
|
|
||||||
size="medium"
|
|
||||||
[value]="platformName"
|
|
||||||
></gf-value>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -50,7 +42,6 @@
|
|||||||
[hasPermissionToOpenDetails]="false"
|
[hasPermissionToOpenDetails]="false"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showActions]="false"
|
[showActions]="false"
|
||||||
[showSymbolColumn]="false"
|
|
||||||
(export)="onExport()"
|
(export)="onExport()"
|
||||||
></gf-activities-table>
|
></gf-activities-table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,13 +19,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
<ng-container matColumnDef="currency">
|
||||||
<th
|
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||||
*matHeaderCellDef
|
<ng-container i18n>Currency</ng-container>
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
i18n
|
|
||||||
mat-header-cell
|
|
||||||
>
|
|
||||||
Currency
|
|
||||||
</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>
|
||||||
{{ element.currency }}
|
{{ element.currency }}
|
||||||
@ -36,13 +31,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
<ng-container matColumnDef="platform">
|
||||||
<th
|
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||||
*matHeaderCellDef
|
<ng-container i18n>Platform</ng-container>
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
i18n
|
|
||||||
mat-header-cell
|
|
||||||
>
|
|
||||||
Platform
|
|
||||||
</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>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@ -81,10 +71,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Cash Balance
|
<ng-container i18n>Cash Balance</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
@ -116,10 +105,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Value
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
@ -151,10 +139,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Value
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
|
@ -69,7 +69,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteAccount(aId: string) {
|
public onDeleteAccount(aId: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this account?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this account?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
this.accountDeleted.emit(aId);
|
this.accountDeleted.emit(aId);
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
@ -105,19 +105,18 @@
|
|||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button i18n mat-menu-item (click)="onViewData(job.data)">
|
<button mat-menu-item (click)="onViewData(job.data)">
|
||||||
View Data
|
<ng-container i18n>View Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="job.stacktrace?.length <= 0"
|
[disabled]="job.stacktrace?.length <= 0"
|
||||||
(click)="onViewStacktrace(job.stacktrace)"
|
(click)="onViewStacktrace(job.stacktrace)"
|
||||||
>
|
>
|
||||||
View Stacktrace
|
<ng-container i18n>View Stacktrace</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
|
<button mat-menu-item (click)="onDeleteJob(job.id)">
|
||||||
Delete Job
|
<ng-container i18n>Delete Job</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -43,8 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
|
<button color="primary" mat-flat-button (click)="onUpdate()">
|
||||||
Save
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -3,17 +3,27 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSort } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { 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 { getDateFormatString } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { 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 { DataSource, MarketData } from '@prisma/client';
|
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
|
public activeFilters: Filter[] = [];
|
||||||
|
public allFilters: Filter[] = [
|
||||||
|
AssetSubClass.BOND,
|
||||||
|
AssetSubClass.COMMODITY,
|
||||||
|
AssetSubClass.CRYPTOCURRENCY,
|
||||||
|
AssetSubClass.ETF,
|
||||||
|
AssetSubClass.MUTUALFUND,
|
||||||
|
AssetSubClass.PRECIOUS_METAL,
|
||||||
|
AssetSubClass.PRIVATE_EQUITY,
|
||||||
|
AssetSubClass.STOCK
|
||||||
|
].map((id) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
});
|
||||||
public currentDataSource: DataSource;
|
public currentDataSource: DataSource;
|
||||||
public currentSymbol: string;
|
public currentSymbol: string;
|
||||||
|
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||||
|
new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public marketData: AdminMarketDataItem[] = [];
|
public deviceType: string;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public displayedColumns = [
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'assetClass',
|
||||||
|
'assetSubClass',
|
||||||
|
'date',
|
||||||
|
'activityCount',
|
||||||
|
'marketDataItemCount',
|
||||||
|
'countriesCount',
|
||||||
|
'sectorsCount',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
public filters$ = new Subject<Filter[]>();
|
||||||
|
public isLoading = false;
|
||||||
|
public placeholder = '';
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -35,8 +80,29 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (
|
||||||
|
params['assetProfileDialog'] &&
|
||||||
|
params['dataSource'] &&
|
||||||
|
params['dateOfFirstActivity'] &&
|
||||||
|
params['symbol']
|
||||||
|
) {
|
||||||
|
this.openAssetProfileDialog({
|
||||||
|
dataSource: params['dataSource'],
|
||||||
|
dateOfFirstActivity: params['dateOfFirstActivity'],
|
||||||
|
symbol: params['symbol']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -51,7 +117,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminMarketData();
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((filters) => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.activeFilters = filters;
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
return this.dataService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -75,54 +165,60 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onOpenAssetProfileDialog({
|
||||||
if (withRefresh) {
|
dataSource,
|
||||||
this.fetchAdminMarketData();
|
dateOfFirstActivity,
|
||||||
this.fetchAdminMarketDataBySymbol({
|
symbol
|
||||||
dataSource: this.currentDataSource,
|
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||||
symbol: this.currentSymbol
|
this.router.navigate([], {
|
||||||
|
queryParams: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetProfileDialog: true,
|
||||||
|
dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
|
|
||||||
this.marketDataDetails = [];
|
|
||||||
|
|
||||||
if (this.currentSymbol === symbol) {
|
|
||||||
this.currentDataSource = undefined;
|
|
||||||
this.currentSymbol = '';
|
|
||||||
} else {
|
|
||||||
this.currentDataSource = dataSource;
|
|
||||||
this.currentSymbol = symbol;
|
|
||||||
|
|
||||||
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminMarketData() {
|
private openAssetProfileDialog({
|
||||||
this.dataService
|
dataSource,
|
||||||
.fetchAdminMarketData()
|
dateOfFirstActivity,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe((user) => {
|
||||||
this.marketData = marketData;
|
this.user = user;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
const dialogRef = this.dialog.open(AssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <AssetProfileDialogParams>{
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
|
symbol,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
dialogRef
|
||||||
this.adminService
|
.afterClosed()
|
||||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe(() => {
|
||||||
this.marketDataDetails = marketData;
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,108 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table class="gf-table w-100">
|
<gf-activities-filter
|
||||||
<thead>
|
[allFilters]="allFilters"
|
||||||
<tr class="mat-header-row">
|
[isLoading]="isLoading"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
[placeholder]="placeholder"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
(valueChanged)="filters$.next($event)"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
></gf-activities-filter>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<div class="row">
|
||||||
</tr>
|
<div class="col">
|
||||||
</thead>
|
<table
|
||||||
<tbody>
|
class="gf-table w-100"
|
||||||
<ng-container *ngFor="let item of marketData; let i = index">
|
matSort
|
||||||
<tr
|
matSortActive="symbol"
|
||||||
class="cursor-pointer mat-row"
|
matSortDirection="asc"
|
||||||
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
mat-table
|
||||||
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
<ng-container matColumnDef="symbol">
|
||||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container i18n>Symbol</ng-container>
|
||||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
</ng-container>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetSubClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Sub Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetSubClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="date">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>First Activity</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activityCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Activity Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.activityCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.marketDataItemCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="countriesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.countriesCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="sectorsCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.sectorsCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -36,44 +113,35 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Data
|
<ng-container i18n>Gather Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Profile Data
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="item.activityCount !== 0"
|
[disabled]="element.activityCount !== 0"
|
||||||
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Delete Profile Data
|
<ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
|
||||||
<td class="p-1" colspan="6">
|
|
||||||
<gf-admin-market-data-detail
|
|
||||||
[dataSource]="item.dataSource"
|
|
||||||
[dateOfFirstActivity]="item.date"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[marketData]="marketDataDetails"
|
|
||||||
[symbol]="item.symbol"
|
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
|
||||||
></gf-admin-market-data-detail>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tbody>
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="cursor-pointer"
|
||||||
|
mat-row
|
||||||
|
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,17 +2,23 @@ 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 { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
|
|
||||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
|
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataComponent],
|
declarations: [AdminMarketDataComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfAdminMarketDataDetailModule,
|
GfActivitiesFilterModule,
|
||||||
|
GfAssetProfileDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule
|
MatMenuModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTableModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { MarketData } from '@prisma/client';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'd-flex flex-column h-100' },
|
||||||
|
selector: 'gf-asset-profile-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: 'asset-profile-dialog.html',
|
||||||
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
|
public marketDataDetails: MarketData[] = [];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClose(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
|
if (withRefresh) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.marketDataDetails = marketData;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize() {
|
||||||
|
this.fetchAdminMarketDataBySymbol({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<gf-dialog-header
|
||||||
|
mat-dialog-title
|
||||||
|
position="center"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[title]="data.symbol"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-header>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<gf-admin-market-data-detail
|
||||||
|
[dataSource]="data.dataSource"
|
||||||
|
[dateOfFirstActivity]="data.dateOfFirstActivity"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[marketData]="marketDataDetails"
|
||||||
|
[symbol]="data.symbol"
|
||||||
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
|
></gf-admin-market-data-detail>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<gf-dialog-footer
|
||||||
|
mat-dialog-actions
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-footer>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
|
||||||
|
import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AssetProfileDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfAdminMarketDataDetailModule,
|
||||||
|
GfDialogFooterModule,
|
||||||
|
GfDialogHeaderModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAssetProfileDialogModule {}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface AssetProfileDialogParams {
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -103,7 +103,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAddCurrency() {
|
public onAddCurrency() {
|
||||||
const currency = prompt('Please add a currency:');
|
const currency = prompt($localize`Please add a currency:`);
|
||||||
|
|
||||||
if (currency) {
|
if (currency) {
|
||||||
const currencies = uniq([...this.customCurrencies, currency]);
|
const currencies = uniq([...this.customCurrencies, currency]);
|
||||||
@ -116,7 +116,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteCoupon(aCouponCode: string) {
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this coupon?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
const coupons = this.coupons.filter((coupon) => {
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
@ -127,7 +129,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this currency?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
const currencies = this.customCurrencies.filter((currency) => {
|
const currencies = this.customCurrencies.filter((currency) => {
|
||||||
@ -142,7 +146,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
const confirmation = confirm('Do you really want to flush the cache?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to flush the cache?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
this.cacheService
|
this.cacheService
|
||||||
@ -190,7 +196,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSetSystemMessage() {
|
public onSetSystemMessage() {
|
||||||
const systemMessage = prompt('Please set your system message:');
|
const systemMessage = prompt($localize`Please set your system message:`);
|
||||||
|
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
this.putSystemMessage(systemMessage);
|
this.putSystemMessage(systemMessage);
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<div class="w-50">{{ userCount }}</div>
|
<div class="w-50">{{ userCount }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Transaction Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<ng-container *ngIf="transactionCount">
|
<ng-container *ngIf="transactionCount">
|
||||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||||
@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
<div class="w-50" i18n>Data Management</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
|
@ -55,7 +55,9 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteUser(aId: string) {
|
public onDeleteUser(aId: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this user?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this user?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
this.dataService
|
this.dataService
|
||||||
|
@ -7,17 +7,17 @@
|
|||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Registration
|
<ng-container i18n>Registration</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Accounts
|
<ng-container i18n>Accounts</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Activities
|
<ng-container i18n>Activities</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Engagement per Day
|
<ng-container i18n>Engagement per Day</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
@ -29,11 +29,10 @@
|
|||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="d-none d-sm-inline-block"
|
<span class="d-none d-sm-inline-block"
|
||||||
>{{ userItem.alias || userItem.id }}</span
|
>{{ userItem.id }}</span
|
||||||
>
|
>
|
||||||
<span class="d-inline-block d-sm-none"
|
<span class="d-inline-block d-sm-none"
|
||||||
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
>{{ (userItem.id | slice:0:5) + '...' }}</span
|
||||||
'...' }}</span
|
|
||||||
>
|
>
|
||||||
<gf-premium-indicator
|
<gf-premium-indicator
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
|
@ -124,13 +124,11 @@
|
|||||||
: 'radio-button-on-outline'
|
: 'radio-button-on-outline'
|
||||||
"
|
"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span *ngIf="user?.alias">{{ user.alias }}</span>
|
<span i18n>Me</span>
|
||||||
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngFor="let accessItem of user?.access"
|
*ngFor="let accessItem of user?.access"
|
||||||
class="align-items-center d-flex"
|
class="align-items-center d-flex"
|
||||||
disabled="false"
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="impersonateAccount(accessItem.id)"
|
(click)="impersonateAccount(accessItem.id)"
|
||||||
>
|
>
|
||||||
@ -287,17 +285,16 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
><ion-icon name="logo-github"></ion-icon
|
><ion-icon name="logo-github"></ion-icon
|
||||||
></a>
|
></a>
|
||||||
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
|
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||||
Sign In
|
<ng-container i18n>Sign in</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/register']"
|
[routerLink]="['/register']"
|
||||||
>Get Started
|
><ng-container i18n>Get started</ng-container>
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -109,7 +109,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
data: {
|
data: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
||||||
title: 'Sign in'
|
title: $localize`Sign in`
|
||||||
},
|
},
|
||||||
width: '30rem'
|
width: '30rem'
|
||||||
});
|
});
|
||||||
@ -123,7 +123,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
.loginAnonymous(data?.accessToken)
|
.loginAnonymous(data?.accessToken)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
alert('Oops! Incorrect Security Token.');
|
alert($localize`Oops! Incorrect Security Token.`);
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
}),
|
||||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||||
|
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { 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';
|
||||||
import {
|
import {
|
||||||
@ -9,7 +10,6 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
|
||||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
@ -27,7 +27,7 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
|
|||||||
})
|
})
|
||||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions = defaultDateRangeOptions;
|
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
@ -47,7 +47,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
route.queryParams
|
this.route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
if (
|
if (
|
||||||
|
@ -21,6 +21,8 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class HomeMarketComponent implements OnDestroy, OnInit {
|
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||||
public benchmarks: Benchmark[];
|
public benchmarks: Benchmark[];
|
||||||
public fearAndGreedIndex: number;
|
public fearAndGreedIndex: number;
|
||||||
|
public fearLabel = $localize`Fear`;
|
||||||
|
public greedLabel = $localize`Greed`;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public historicalData: HistoricalDataItem[];
|
public historicalData: HistoricalDataItem[];
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
@ -9,13 +9,13 @@
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
yMax="100"
|
||||||
yMaxLabel="Greed"
|
|
||||||
yMin="0"
|
yMin="0"
|
||||||
yMinLabel="Fear"
|
|
||||||
[historicalDataItems]="historicalData"
|
[historicalDataItems]="historicalData"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[yMaxLabel]="greedLabel"
|
||||||
|
[yMinLabel]="fearLabel"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<gf-fear-and-greed-index
|
<gf-fear-and-greed-index
|
||||||
class="d-flex justify-content-center"
|
class="d-flex justify-content-center"
|
||||||
@ -34,6 +34,7 @@
|
|||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
*ngIf="isLoading"
|
*ngIf="isLoading"
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
|
class="px-2 py-3"
|
||||||
[theme]="{
|
[theme]="{
|
||||||
height: '1.5rem',
|
height: '1.5rem',
|
||||||
width: '100%'
|
width: '100%'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
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';
|
||||||
import {
|
import {
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
UniqueAsset,
|
UniqueAsset,
|
||||||
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions = defaultDateRangeOptions;
|
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public errors: UniqueAsset[];
|
public errors: UniqueAsset[];
|
||||||
public hasError: boolean;
|
public hasError: boolean;
|
||||||
|
@ -2,19 +2,8 @@
|
|||||||
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||||
>
|
>
|
||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="chart-container col">
|
<div class="col p-0">
|
||||||
<gf-line-chart
|
<div class="chart-container mx-auto position-relative">
|
||||||
class="position-absolute"
|
|
||||||
symbol="Performance"
|
|
||||||
[currency]="user?.settings?.baseCurrency"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
|
||||||
[showGradient]="true"
|
|
||||||
[showLoader]="false"
|
|
||||||
[showXAxis]="false"
|
|
||||||
[showYAxis]="false"
|
|
||||||
></gf-line-chart>
|
|
||||||
<div
|
<div
|
||||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||||
@ -23,6 +12,20 @@
|
|||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<gf-line-chart
|
||||||
|
class="position-absolute"
|
||||||
|
symbol="Performance"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[hidden]="historicalDataItems?.length === 0"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||||
|
[showGradient]="true"
|
||||||
|
[showLoader]="false"
|
||||||
|
[showXAxis]="false"
|
||||||
|
[showYAxis]="false"
|
||||||
|
></gf-line-chart>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-container row mt-1">
|
<div class="overview-container row mt-1">
|
||||||
|
@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
max-height: 50vh;
|
height: auto;
|
||||||
|
max-width: 50rem;
|
||||||
|
|
||||||
// Fallback for aspect-ratio (using padding hack)
|
// Fallback for aspect-ratio (using padding hack)
|
||||||
@supports not (aspect-ratio: 16 / 9) {
|
@supports not (aspect-ratio: 16 / 9) {
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
ngx-skeleton-loader {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
getTooltipPositionerMapTop,
|
getTooltipPositionerMapTop,
|
||||||
getVerticalHoverLinePlugin
|
getVerticalHoverLinePlugin
|
||||||
} from '@ghostfolio/common/chart-helper';
|
} from '@ghostfolio/common/chart-helper';
|
||||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
getBackgroundColor,
|
getBackgroundColor,
|
||||||
getDateFormatString,
|
getDateFormatString,
|
||||||
@ -22,7 +22,10 @@ import {
|
|||||||
transformTickToAbbreviation
|
transformTickToAbbreviation
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
Chart,
|
Chart,
|
||||||
LineController,
|
LineController,
|
||||||
LineElement,
|
LineElement,
|
||||||
@ -31,6 +34,7 @@ import {
|
|||||||
TimeScale,
|
TimeScale,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -42,9 +46,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
|||||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||||
@Input() currency: string;
|
@Input() currency: string;
|
||||||
@Input() daysInMarket: number;
|
@Input() daysInMarket: number;
|
||||||
|
@Input() groupBy: GroupBy;
|
||||||
@Input() investments: InvestmentItem[];
|
@Input() investments: InvestmentItem[];
|
||||||
@Input() isInPercent = false;
|
@Input() isInPercent = false;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
|
@Input() savingsRate = 0;
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
@ -53,6 +59,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
Chart.register(
|
Chart.register(
|
||||||
|
annotationPlugin,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
LineController,
|
LineController,
|
||||||
LineElement,
|
LineElement,
|
||||||
@ -78,7 +87,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
private initialize() {
|
private initialize() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
if (this.investments?.length > 0) {
|
if (!this.groupBy && this.investments?.length > 0) {
|
||||||
// Extend chart by 5% of days in market (before)
|
// Extend chart by 5% of days in market (before)
|
||||||
const firstItem = this.investments[0];
|
const firstItem = this.investments[0];
|
||||||
this.investments.unshift({
|
this.investments.unshift({
|
||||||
@ -102,17 +111,18 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: this.investments.map((position) => {
|
labels: this.investments.map((investmentItem) => {
|
||||||
return position.date;
|
return investmentItem.date;
|
||||||
}),
|
}),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
|
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||||
borderWidth: 2,
|
borderWidth: this.groupBy ? 0 : 2,
|
||||||
data: this.investments.map((position) => {
|
data: this.investments.map((position) => {
|
||||||
return position.investment;
|
return position.investment;
|
||||||
}),
|
}),
|
||||||
label: 'Investment',
|
label: $localize`Deposit`,
|
||||||
segment: {
|
segment: {
|
||||||
borderColor: (context: unknown) =>
|
borderColor: (context: unknown) =>
|
||||||
this.isInFuture(
|
this.isInFuture(
|
||||||
@ -137,6 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||||
data,
|
data,
|
||||||
options: {
|
options: {
|
||||||
|
animation: false,
|
||||||
elements: {
|
elements: {
|
||||||
line: {
|
line: {
|
||||||
tension: 0
|
tension: 0
|
||||||
@ -150,6 +161,39 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
interaction: { intersect: false, mode: 'index' },
|
interaction: { intersect: false, mode: 'index' },
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
plugins: <unknown>{
|
plugins: <unknown>{
|
||||||
|
annotation: {
|
||||||
|
annotations: {
|
||||||
|
savingsRate: this.savingsRate
|
||||||
|
? {
|
||||||
|
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||||
|
borderRadius: 2,
|
||||||
|
color: 'white',
|
||||||
|
content: 'Savings Rate',
|
||||||
|
display: true,
|
||||||
|
font: { size: '10px', weight: 'normal' },
|
||||||
|
padding: {
|
||||||
|
x: 4,
|
||||||
|
y: 2
|
||||||
|
},
|
||||||
|
position: 'start'
|
||||||
|
},
|
||||||
|
scaleID: 'y',
|
||||||
|
type: 'line',
|
||||||
|
value: this.savingsRate
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
yAxis: {
|
||||||
|
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||||
|
borderWidth: 1,
|
||||||
|
scaleID: 'y',
|
||||||
|
type: 'line',
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
@ -164,6 +208,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||||
|
borderWidth: this.groupBy ? 0 : 1,
|
||||||
color: `rgba(${getTextColor()}, 0.8)`,
|
color: `rgba(${getTextColor()}, 0.8)`,
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
@ -178,8 +223,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
grid: {
|
grid: {
|
||||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||||
color: `rgba(${getTextColor()}, 0.8)`,
|
color: `rgba(${getTextColor()}, 0.8)`,
|
||||||
display: false
|
display: false,
|
||||||
|
drawBorder: false
|
||||||
},
|
},
|
||||||
|
position: 'right',
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: (value: number) => {
|
callback: (value: number) => {
|
||||||
return transformTickToAbbreviation(value);
|
return transformTickToAbbreviation(value);
|
||||||
@ -192,13 +239,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
||||||
type: 'line'
|
type: this.groupBy ? 'bar' : 'line'
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTooltipPluginConfiguration() {
|
private getTooltipPluginConfiguration() {
|
||||||
return {
|
return {
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
|
||||||
import {
|
import {
|
||||||
STAY_SIGNED_IN,
|
STAY_SIGNED_IN,
|
||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-login-with-access-token-dialog',
|
selector: 'gf-login-with-access-token-dialog',
|
||||||
@ -16,7 +19,10 @@ export class LoginWithAccessTokenDialog {
|
|||||||
public constructor(
|
public constructor(
|
||||||
@Inject(MAT_DIALOG_DATA) public data: any,
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
|
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
|
||||||
private settingsStorageService: SettingsStorageService
|
private internetIdentityService: InternetIdentityService,
|
||||||
|
private router: Router,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private tokenStorageService: TokenStorageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {}
|
ngOnInit() {}
|
||||||
@ -31,4 +37,14 @@ export class LoginWithAccessTokenDialog {
|
|||||||
public onClose() {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async onLoginWithInternetIdentity() {
|
||||||
|
try {
|
||||||
|
const { authToken } = await this.internetIdentityService.login();
|
||||||
|
|
||||||
|
this.tokenStorageService.saveToken(authToken);
|
||||||
|
this.dialogRef.close();
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,16 +5,7 @@
|
|||||||
></gf-dialog-header>
|
></gf-dialog-header>
|
||||||
|
|
||||||
<div mat-dialog-content>
|
<div mat-dialog-content>
|
||||||
<div>
|
<div class="align-items-center d-flex flex-column">
|
||||||
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
|
|
||||||
<div class="text-center">
|
|
||||||
<a color="accent" href="/api/v1/auth/google" mat-flat-button
|
|
||||||
><ion-icon class="mr-1" name="logo-google"></ion-icon
|
|
||||||
><span i18n>Sign in with Google</span></a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="my-3 text-center text-muted" i18n>or</div>
|
|
||||||
</ng-container>
|
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Security Token</mat-label>
|
<mat-label i18n>Security Token</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -24,6 +15,29 @@
|
|||||||
[(ngModel)]="data.accessToken"
|
[(ngModel)]="data.accessToken"
|
||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
|
||||||
|
<div class="my-3 text-center text-muted" i18n>or</div>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<button
|
||||||
|
class="mb-2"
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="onLoginWithInternetIdentity()"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="mr-2"
|
||||||
|
src="../assets/icons/internet-computer.svg"
|
||||||
|
style="height: 0.75rem"
|
||||||
|
/><span i18n>Sign in with Internet Identity</span>
|
||||||
|
</button>
|
||||||
|
<a href="../api/v1/auth/google" mat-stroked-button
|
||||||
|
><img
|
||||||
|
class="mr-2"
|
||||||
|
src="../assets/icons/google.svg"
|
||||||
|
style="height: 1rem"
|
||||||
|
/><span i18n>Sign in with Google</span></a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div mat-dialog-actions>
|
<div mat-dialog-actions>
|
||||||
@ -35,12 +49,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!data.accessToken"
|
[disabled]="!data.accessToken"
|
||||||
[mat-dialog-close]="data"
|
[mat-dialog-close]="data"
|
||||||
>
|
>
|
||||||
Sign in
|
<ng-container i18n>Sign in</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,4 +12,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-form-field {
|
||||||
|
::ng-deep {
|
||||||
|
.mat-form-field-wrapper {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public onEditEmergencyFund() {
|
public onEditEmergencyFund() {
|
||||||
const emergencyFundInput = prompt(
|
const emergencyFundInput = prompt(
|
||||||
'Please enter the amount of your emergency fund:',
|
$localize`Please enter the amount of your emergency fund:`,
|
||||||
this.summary.emergencyFund.toString()
|
this.summary.emergencyFund.toString()
|
||||||
);
|
);
|
||||||
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
||||||
|
@ -35,112 +35,124 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Change"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
[currency]="data.baseCurrency"
|
[currency]="data.baseCurrency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="netPerformance"
|
[value]="netPerformance"
|
||||||
></gf-value>
|
>Change</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Performance"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
[isPercent]="true"
|
[isPercent]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="netPerformancePercent"
|
[value]="netPerformancePercent"
|
||||||
></gf-value>
|
>Performance</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Average Unit Price"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[currency]="SymbolProfile?.currency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="averagePrice"
|
[value]="averagePrice"
|
||||||
></gf-value>
|
>Average Unit Price</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Market Price"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[currency]="SymbolProfile?.currency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="marketPrice"
|
[value]="marketPrice"
|
||||||
></gf-value>
|
>Market Price</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Minimum Price"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[currency]="SymbolProfile?.currency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||||
[value]="minPrice"
|
[value]="minPrice"
|
||||||
></gf-value>
|
>Minimum Price</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Maximum Price"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[currency]="SymbolProfile?.currency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||||
[value]="maxPrice"
|
[value]="maxPrice"
|
||||||
></gf-value>
|
>Maximum Price</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Quantity"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[precision]="quantityPrecision"
|
[precision]="quantityPrecision"
|
||||||
[value]="quantity"
|
[value]="quantity"
|
||||||
></gf-value>
|
>Quantity</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Investment"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="data.baseCurrency"
|
[currency]="data.baseCurrency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="investment"
|
[value]="investment"
|
||||||
></gf-value>
|
>Investment</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="First Buy Date"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[isDate]="true"
|
[isDate]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="firstBuyDate"
|
[value]="firstBuyDate"
|
||||||
></gf-value>
|
>First Buy Date</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
|
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
></gf-value>
|
>Transactions</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Asset Class"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[hidden]="!SymbolProfile?.assetClass"
|
[hidden]="!SymbolProfile?.assetClass"
|
||||||
[value]="SymbolProfile?.assetClass"
|
[value]="SymbolProfile?.assetClass"
|
||||||
></gf-value>
|
>Asset Class</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Asset Sub Class"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[hidden]="!SymbolProfile?.assetSubClass"
|
[hidden]="!SymbolProfile?.assetSubClass"
|
||||||
[value]="SymbolProfile?.assetSubClass"
|
[value]="SymbolProfile?.assetSubClass"
|
||||||
></gf-value>
|
>Asset Sub Class</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
|
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
|
||||||
@ -150,22 +162,24 @@
|
|||||||
>
|
>
|
||||||
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
|
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Sector"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="SymbolProfile.sectors[0].name"
|
[value]="SymbolProfile.sectors[0].name"
|
||||||
></gf-value>
|
>Sector</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
*ngIf="SymbolProfile?.countries?.length === 1"
|
*ngIf="SymbolProfile?.countries?.length === 1"
|
||||||
class="col-6 mb-3"
|
class="col-6 mb-3"
|
||||||
>
|
>
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Country"
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="SymbolProfile.countries[0].name"
|
[value]="SymbolProfile.countries[0].name"
|
||||||
></gf-value>
|
>Country</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #charts>
|
<ng-template #charts>
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="symbol">
|
<ng-container matColumnDef="symbol">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
Symbol
|
<ng-container i18n>Symbol</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
|
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
|
||||||
@ -30,11 +30,10 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1"
|
class="d-none d-lg-table-cell px-1"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Name
|
<ng-container i18n>Name</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>
|
||||||
<ng-container *ngIf="element.name !== element.symbol">{{
|
<ng-container *ngIf="element.name !== element.symbol">{{
|
||||||
@ -47,11 +46,10 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Value
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
|
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
@ -68,11 +66,10 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="justify-content-end px-1"
|
class="justify-content-end px-1"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Allocation
|
<ng-container i18n>Allocation</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
@ -89,10 +86,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Performance
|
<ng-container i18n>Performance</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>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
@ -137,9 +133,20 @@
|
|||||||
*ngIf="dataSource.data.length > pageSize && !isLoading"
|
*ngIf="dataSource.data.length > pageSize && !isLoading"
|
||||||
class="my-3 text-center"
|
class="my-3 text-center"
|
||||||
>
|
>
|
||||||
<button i18n mat-stroked-button (click)="onShowAllPositions()">
|
<button mat-stroked-button (click)="onShowAllPositions()">
|
||||||
Show all
|
<ng-container i18n>Show all</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>
|
||||||
|
@ -27,6 +27,7 @@ import { Subject, Subscription } from 'rxjs';
|
|||||||
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() hasPermissionToCreateActivity: boolean;
|
||||||
@Input() hasPermissionToShowValues = true;
|
@Input() hasPermissionToShowValues = true;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() pageSize = Number.MAX_SAFE_INTEGER;
|
@Input() pageSize = Number.MAX_SAFE_INTEGER;
|
||||||
|
@ -17,6 +17,14 @@ import { ToggleOption } from '@ghostfolio/common/types';
|
|||||||
styleUrls: ['./toggle.component.scss']
|
styleUrls: ['./toggle.component.scss']
|
||||||
})
|
})
|
||||||
export class ToggleComponent implements OnChanges, OnInit {
|
export class ToggleComponent implements OnChanges, OnInit {
|
||||||
|
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
|
||||||
|
{ label: $localize`Today`, value: '1d' },
|
||||||
|
{ label: $localize`YTD`, value: 'ytd' },
|
||||||
|
{ label: $localize`1Y`, value: '1y' },
|
||||||
|
{ label: $localize`5Y`, value: '5y' },
|
||||||
|
{ label: $localize`Max`, value: 'max' }
|
||||||
|
];
|
||||||
|
|
||||||
@Input() defaultValue: string;
|
@Input() defaultValue: string;
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
@Input() options: ToggleOption[];
|
@Input() options: ToggleOption[];
|
||||||
|
@ -21,6 +21,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/demo',
|
'/demo',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
|
'/faq',
|
||||||
'/features',
|
'/features',
|
||||||
'/markets',
|
'/markets',
|
||||||
'/p',
|
'/p',
|
||||||
@ -71,7 +72,13 @@ export class AuthGuard implements CanActivate {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe((user) => {
|
.subscribe((user) => {
|
||||||
if (
|
const userLanguage = user?.settings?.language;
|
||||||
|
|
||||||
|
if (userLanguage && document.documentElement.lang !== userLanguage) {
|
||||||
|
window.location.href = `../${userLanguage}`;
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
} else if (
|
||||||
state.url.startsWith('/home') &&
|
state.url.startsWith('/home') &&
|
||||||
user.settings.viewMode === ViewMode.ZEN
|
user.settings.viewMode === ViewMode.ZEN
|
||||||
) {
|
) {
|
||||||
|
@ -56,14 +56,18 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
if (!this.snackBarRef) {
|
if (!this.snackBarRef) {
|
||||||
if (this.info.isReadOnlyMode) {
|
if (this.info.isReadOnlyMode) {
|
||||||
this.snackBarRef = this.snackBar.open(
|
this.snackBarRef = this.snackBar.open(
|
||||||
'This feature is currently unavailable. Please try again later.',
|
$localize`This feature is currently unavailable.` +
|
||||||
|
' ' +
|
||||||
|
$localize`Please try again later.`,
|
||||||
undefined,
|
undefined,
|
||||||
{ duration: 6000 }
|
{ duration: 6000 }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.snackBarRef = this.snackBar.open(
|
this.snackBarRef = this.snackBar.open(
|
||||||
'This feature requires a subscription.',
|
$localize`This feature requires a subscription.`,
|
||||||
this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
|
this.hasPermissionForSubscription
|
||||||
|
? $localize`Upgrade Plan`
|
||||||
|
: undefined,
|
||||||
{ duration: 6000 }
|
{ duration: 6000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -79,8 +83,10 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
|
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
|
||||||
if (!this.snackBarRef) {
|
if (!this.snackBarRef) {
|
||||||
this.snackBarRef = this.snackBar.open(
|
this.snackBarRef = this.snackBar.open(
|
||||||
'Oops! Something went wrong. Please try again later.',
|
$localize`Oops! Something went wrong.` +
|
||||||
'Okay',
|
' ' +
|
||||||
|
$localize`Please try again later.`,
|
||||||
|
$localize`Okay`,
|
||||||
{ duration: 6000 }
|
{ duration: 6000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { AboutPageComponent } from './about-page.component';
|
import { AboutPageComponent } from './about-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: AboutPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`About`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
|
<h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
|
||||||
<div class="about-container">
|
<div class="about-container">
|
||||||
<p>
|
<p>
|
||||||
Ghostfolio is a lightweight wealth management application for
|
Ghostfolio is a lightweight wealth management application for
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<ng-container *ngIf="version">
|
<ng-container *ngIf="version">
|
||||||
This instance is running Ghostfolio {{ version }}.
|
This instance is running Ghostfolio {{ version }}.
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="hasPermissionForStatistics" i18n
|
<ng-container *ngIf="hasPermissionForStatistics"
|
||||||
>Check the system status at
|
>Check the system status at
|
||||||
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
||||||
>status.ghostfol.io</a
|
>status.ghostfol.io</a
|
||||||
@ -102,33 +102,36 @@
|
|||||||
|
|
||||||
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
|
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
|
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Active Users"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
subLabel="(Last 24 hours)"
|
subLabel="(Last 24 hours)"
|
||||||
[value]="statistics?.activeUsers1d ?? '-'"
|
[value]="statistics?.activeUsers1d ?? '-'"
|
||||||
></gf-value>
|
>Active Users</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="New Users"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
subLabel="(Last 30 days)"
|
subLabel="(Last 30 days)"
|
||||||
[value]="statistics?.newUsers30d ?? '-'"
|
[value]="statistics?.newUsers30d ?? '-'"
|
||||||
></gf-value>
|
>New Users</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Active Users"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
subLabel="(Last 30 days)"
|
subLabel="(Last 30 days)"
|
||||||
[value]="statistics?.activeUsers30d ?? '-'"
|
[value]="statistics?.activeUsers30d ?? '-'"
|
||||||
></gf-value>
|
>Active Users</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<a
|
<a
|
||||||
@ -136,10 +139,11 @@
|
|||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
>
|
>
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Users in Slack community"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
[value]="statistics?.slackCommunityUsers ?? '-'"
|
[value]="statistics?.slackCommunityUsers ?? '-'"
|
||||||
></gf-value>
|
>Users in Slack community</gf-value
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
@ -148,10 +152,11 @@
|
|||||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||||
>
|
>
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Contributors on GitHub"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
[value]="statistics?.gitHubContributors ?? '-'"
|
[value]="statistics?.gitHubContributors ?? '-'"
|
||||||
></gf-value>
|
>Contributors on GitHub</gf-value
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
@ -160,10 +165,11 @@
|
|||||||
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||||
>
|
>
|
||||||
<gf-value
|
<gf-value
|
||||||
label="Stars on GitHub"
|
i18n
|
||||||
size="large"
|
size="large"
|
||||||
[value]="statistics?.gitHubStargazers ?? '-'"
|
[value]="statistics?.gitHubStargazers ?? '-'"
|
||||||
></gf-value>
|
>Stars on GitHub</gf-value
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -173,34 +179,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||||
|
<a
|
||||||
|
class="py-2 w-100"
|
||||||
|
color="primary"
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]="['/faq']"
|
||||||
|
>FAQ</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="col-md-4 col-xs-12 my-2"
|
class="col-md-3 col-xs-12 my-2"
|
||||||
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-2 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
[routerLink]="['/about', 'changelog']"
|
[routerLink]="['/about', 'changelog']"
|
||||||
>Changelog & License</a
|
>Changelog & License</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-2 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
[routerLink]="['/about', 'privacy-policy']"
|
[routerLink]="['/about', 'privacy-policy']"
|
||||||
>Privacy Policy</a
|
>Privacy Policy</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-2 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/blog']"
|
[routerLink]="['/blog']"
|
||||||
>Blog</a
|
>Blog</a
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { ChangelogPageComponent } from './changelog-page.component';
|
import { ChangelogPageComponent } from './changelog-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: ChangelogPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`Changelog & License`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||||
<mat-card class="changelog">
|
<mat-card class="changelog">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
@ -15,7 +15,7 @@
|
|||||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<markdown [src]="'assets/LICENSE'"></markdown>
|
<markdown [src]="'../assets/LICENSE'"></markdown>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
|
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: PrivacyPolicyPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`Privacy Policy`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
||||||
<markdown [src]="'assets/privacy-policy.md'"></markdown>
|
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { AccountPageComponent } from './account-page.component';
|
import { AccountPageComponent } from './account-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: AccountPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`My Ghostfolio`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -53,6 +53,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToDeleteAccess: boolean;
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public language = document.documentElement.lang;
|
||||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
|
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
@ -162,6 +163,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.user = user;
|
this.user = user;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
if (aKey === 'language') {
|
||||||
|
if (aValue) {
|
||||||
|
window.location.href = `../${aValue}/account`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `../`;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -218,7 +227,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onRedeemCoupon() {
|
public onRedeemCoupon() {
|
||||||
let couponCode = prompt('Please enter your coupon code:');
|
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||||
couponCode = couponCode?.trim();
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
if (couponCode) {
|
if (couponCode) {
|
||||||
@ -227,17 +236,21 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeSubject),
|
takeUntil(this.unsubscribeSubject),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
|
this.snackBar.open(
|
||||||
|
'😞 ' + $localize`Could not redeem coupon code`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
duration: 3000
|
duration: 3000
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.snackBarRef = this.snackBar.open(
|
this.snackBarRef = this.snackBar.open(
|
||||||
'✅ Coupon code has been redeemed',
|
'✅' + $localize`Coupon code has been redeemed`,
|
||||||
'Reload',
|
$localize`Reload`,
|
||||||
{
|
{
|
||||||
duration: 3000
|
duration: 3000
|
||||||
}
|
}
|
||||||
@ -283,7 +296,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.registerDevice();
|
this.registerDevice();
|
||||||
} else {
|
} else {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
'Do you really want to remove this sign in method?'
|
$localize`Do you really want to remove this sign in method?`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
|
@ -8,10 +8,6 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div *ngIf="user.alias" class="d-flex py-1">
|
|
||||||
<div class="pr-1 w-50" i18n>Alias</div>
|
|
||||||
<div class="pl-1 w-50">{{ user.alias }}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
|
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
|
||||||
class="d-flex py-1"
|
class="d-flex py-1"
|
||||||
@ -35,11 +31,10 @@
|
|||||||
<ng-container *ngIf="hasPermissionForSubscription">
|
<ng-container *ngIf="hasPermissionForSubscription">
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onCheckout(priceId)"
|
(click)="onCheckout(priceId)"
|
||||||
>
|
>
|
||||||
Upgrade
|
<ng-container i18n>Upgrade</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div *ngIf="price" class="mt-1">
|
<div *ngIf="price" class="mt-1">
|
||||||
<ng-container *ngIf="coupon"
|
<ng-container *ngIf="coupon"
|
||||||
@ -95,8 +90,8 @@
|
|||||||
<div class="d-flex mt-4 py-1">
|
<div class="d-flex mt-4 py-1">
|
||||||
<form #changeUserSettingsForm="ngForm" class="w-100">
|
<form #changeUserSettingsForm="ngForm" class="w-100">
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
|
<div class="align-items-center d-flex pt-1 pt-1 w-50">
|
||||||
Base Currency
|
<ng-container i18n>Base Currency</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -115,11 +110,30 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center d-flex mb-2">
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Language</div>
|
||||||
|
<div class="hint-text text-muted" i18n>Beta</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-select
|
||||||
|
name="language"
|
||||||
|
[value]="language"
|
||||||
|
(selectionChange)="onChangeUserSetting('language', $event.value)"
|
||||||
|
>
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
|
<mat-option value="de">Deutsch</mat-option>
|
||||||
|
<mat-option value="en">English</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<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>Locale</div>
|
<div i18n>Locale</div>
|
||||||
<div class="hint-text text-muted" i18n>
|
<div class="hint-text text-muted">
|
||||||
Date and number format
|
<ng-container i18n>Date and number format</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
@ -141,8 +155,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
|
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||||
View Mode
|
<ng-container i18n>View Mode</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<div class="align-items-center d-flex overflow-hidden">
|
<div class="align-items-center d-flex overflow-hidden">
|
||||||
|
@ -14,12 +14,11 @@
|
|||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccessForm.form.valid"
|
[disabled]="!addAccessForm.form.valid"
|
||||||
[mat-dialog-close]="data"
|
[mat-dialog-close]="data"
|
||||||
>
|
>
|
||||||
Save
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { AccountsPageComponent } from './accounts-page.component';
|
import { AccountsPageComponent } from './accounts-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: AccountsPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`Accounts`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -66,12 +66,11 @@
|
|||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccountForm.form.valid"
|
[disabled]="!addAccountForm.form.valid"
|
||||||
[mat-dialog-close]="data"
|
[mat-dialog-close]="data"
|
||||||
>
|
>
|
||||||
Save
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -10,8 +10,6 @@ import { AdminPageComponent } from './admin-page.component';
|
|||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
|
||||||
component: AdminPageComponent,
|
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
@ -19,7 +17,10 @@ const routes: Routes = [
|
|||||||
{ path: 'market-data', component: AdminMarketDataComponent },
|
{ path: 'market-data', component: AdminMarketDataComponent },
|
||||||
{ path: 'overview', component: AdminOverviewComponent },
|
{ path: 'overview', component: AdminOverviewComponent },
|
||||||
{ path: 'users', component: AdminUsersComponent }
|
{ path: 'users', component: AdminUsersComponent }
|
||||||
]
|
],
|
||||||
|
component: AdminPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`Admin Control`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@ import { RouterModule, Routes } from '@angular/router';
|
|||||||
import { AuthPageComponent } from './auth-page.component';
|
import { AuthPageComponent } from './auth-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AuthPageComponent },
|
{ component: AuthPageComponent, path: '' },
|
||||||
{ path: ':jwt', component: AuthPageComponent }
|
{ component: AuthPageComponent, path: ':jwt' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -28,6 +28,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
const jwt = params['jwt'];
|
const jwt = params['jwt'];
|
||||||
|
|
||||||
this.tokenStorageService.saveToken(
|
this.tokenStorageService.saveToken(
|
||||||
jwt,
|
jwt,
|
||||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user