Compare commits
49 Commits
Author | SHA1 | Date | |
---|---|---|---|
c8f6fdbaa3 | |||
d95fc82f95 | |||
31c949f9d2 | |||
f68f40fcc6 | |||
9623a363ed | |||
2d42549967 | |||
c934c5088b | |||
678b3cc57e | |||
cd5eb64a4c | |||
fc1507de4f | |||
d147a66dcd | |||
33fd1282e5 | |||
693ff9d3ea | |||
21e87a0055 | |||
43426c9b01 | |||
3b4da72ea3 | |||
8d8e55fd0b | |||
ca18621ce8 | |||
b8574d24b2 | |||
6d12c27f9c | |||
c2c5326049 | |||
2a1339b61e | |||
c8a2579624 | |||
832ae063df | |||
b5e026934f | |||
901c997908 | |||
3b6e0b20e2 | |||
e449d51c3c | |||
f72d31bab3 | |||
4c893c4dcc | |||
ffb11cd10e | |||
d424b7731e | |||
6043c87481 | |||
fca0a688b6 | |||
5c6cc4fed5 | |||
64a7d38ff9 | |||
68d0d39161 | |||
233a8a8a18 | |||
190779ee35 | |||
6ef8121561 | |||
58bf57d1e6 | |||
71c5412dd5 | |||
ae85398c3d | |||
048900d01b | |||
074b09b543 | |||
f9e04022f4 | |||
8fd1fbd44a | |||
0fb33ae71c | |||
3a35d72ec2 |
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -5,11 +5,11 @@
|
||||
"name": "Debug Jest File",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"args": [
|
||||
"test",
|
||||
"--codeCoverage=false",
|
||||
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
|
||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "internalConsole"
|
||||
|
86
CHANGELOG.md
86
CHANGELOG.md
@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.212.0 - 11.11.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the view mode selector to a slide toggle
|
||||
- Upgraded `Nx` from version `15.0.0` to `15.0.13`
|
||||
|
||||
## 1.211.0 - 11.11.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Converted the client into a _Progressive Web App_ (PWA) with `@angular/pwa`
|
||||
- Removed the bottom margin from the body element
|
||||
- Improved the pricing page
|
||||
|
||||
## 1.210.0 - 08.11.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added tabs to the portfolio page
|
||||
|
||||
### Changed
|
||||
|
||||
- Merged the _FIRE_ calculator and the _X-ray_ section to a single page
|
||||
- Tightened the validation rule of the base currency environment variable (`BASE_CURRENCY`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the cash positions calculation
|
||||
|
||||
## 1.209.0 - 05.11.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Buy me a coffee_ button to the about page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the activities import
|
||||
- Improved the usage of the premium indicator component
|
||||
- Removed the intro image in dark mode
|
||||
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
|
||||
|
||||
## 1.208.0 - 03.11.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added pagination to the activities table
|
||||
|
||||
### Changed
|
||||
|
||||
- Restructured the actions in the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculation in the portfolio evolution chart
|
||||
|
||||
## 1.207.0 - 31.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for translated labels of asset and asset sub class
|
||||
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
|
||||
|
||||
### Changed
|
||||
|
||||
- Darkened the background color of the dark mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the public page
|
||||
- Improved the loading indicator of the portfolio evolution chart
|
||||
|
||||
## 1.206.2 - 20.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the `rxjs` version to `7.5.6` (resolutions)
|
||||
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
|
||||
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
|
||||
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the performance calculation including `SELL` activities with a significant performance gain
|
||||
|
||||
## 1.205.2 - 16.10.2022
|
||||
|
||||
### Changed
|
||||
|
@ -25,7 +25,6 @@ RUN yarn install
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
|
66
README.md
66
README.md
@ -53,7 +53,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -81,22 +81,32 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||
<img
|
||||
alt="Buy me a coffee button"
|
||||
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
|
||||
width="150"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### 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` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| 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.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. 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` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -128,7 +138,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
@ -158,7 +168,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Start Server
|
||||
@ -190,20 +200,22 @@ Run `yarn test`
|
||||
|
||||
## Public API
|
||||
|
||||
### Authorization: Bearer Token
|
||||
|
||||
Set the header for each request as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
@ -254,6 +266,10 @@ Set the header as follows:
|
||||
}
|
||||
```
|
||||
|
||||
## Community Projects
|
||||
|
||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
395
angular.json
395
angular.json
@ -1,395 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "apps/client",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
57
apps/api/project.json
Normal file
57
apps/api/project.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "api",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BTCUSD':
|
||||
if (isSameDay(parseDate('2015-01-01'), date)) {
|
||||
return { marketPrice: 314.25 };
|
||||
} else if (isSameDay(parseDate('2017-12-31'), date)) {
|
||||
return { marketPrice: 14156.4 };
|
||||
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||
return { marketPrice: 13657.2 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
|
@ -0,0 +1,110 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(320.43)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(14156.4)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13657.2'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
marketPrice: 13657.2,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-31', investment: new Big('320.43') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
|
@ -0,0 +1,130 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(chartData[0]).toEqual({
|
||||
date: '2022-03-07',
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformance: 0,
|
||||
totalInvestment: 151.6,
|
||||
value: 151.6
|
||||
});
|
||||
|
||||
expect(chartData[chartData.length - 1]).toEqual({
|
||||
date: '2022-04-11',
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformance: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 19.86
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
investment: new Big('0'),
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||
{ date: '2022-04-08', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -234,21 +234,28 @@ export class PortfolioCalculator {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const maxInvestmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
for (const symbol of Object.keys(symbols)) {
|
||||
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
||||
this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
||||
}
|
||||
|
||||
for (const currentDate of dates) {
|
||||
@ -267,19 +274,28 @@ export class PortfolioCalculator {
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
maxTotalInvestmentValues[dateString] =
|
||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
||||
dateString
|
||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(totalInvestmentValues[date])
|
||||
.div(maxTotalInvestmentValues[date])
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
@ -899,13 +915,10 @@ export class PortfolioCalculator {
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
@ -1000,6 +1013,12 @@ export class PortfolioCalculator {
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(i + 1, order.type, order.itemType);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
@ -1027,9 +1046,21 @@ export class PortfolioCalculator {
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
const transactionInvestment =
|
||||
order.type === 'BUY'
|
||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
@ -1078,59 +1109,23 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSell',
|
||||
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
@ -1142,6 +1137,15 @@ export class PortfolioCalculator {
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
@ -1149,12 +1153,6 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
@ -1218,6 +1216,7 @@ export class PortfolioCalculator {
|
||||
Average price: ${averagePriceAtStartDate.toFixed(
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
@ -1233,6 +1232,7 @@ export class PortfolioCalculator {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
|
@ -10,7 +10,6 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
@ -72,8 +71,13 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -134,7 +138,13 @@ export class PortfolioController {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasDetails === false ||
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
@ -152,11 +162,6 @@ export class PortfolioController {
|
||||
]);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
holdings[symbol] = {
|
||||
...portfolioPosition,
|
||||
@ -176,7 +181,7 @@ export class PortfolioController {
|
||||
hasError,
|
||||
holdings,
|
||||
totalValueInBaseCurrency,
|
||||
summary: hasDetails ? portfolioSummary : undefined
|
||||
summary: portfolioSummary
|
||||
};
|
||||
}
|
||||
|
||||
@ -187,16 +192,6 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
@ -227,6 +222,15 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { investments };
|
||||
}
|
||||
|
||||
@ -240,7 +244,8 @@ export class PortfolioController {
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
@ -274,6 +279,17 @@ export class PortfolioController {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
(item) => {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
}
|
||||
|
||||
@ -331,7 +347,7 @@ export class PortfolioController {
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: access.userId
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
@ -413,16 +429,19 @@ export class PortfolioController {
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
for (const rule in report.rules) {
|
||||
if (report.rules[rule]) {
|
||||
report.rules[rule] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
@ -32,11 +31,13 @@ import {
|
||||
HistoricalDataItem,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition,
|
||||
UserSettings
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -67,11 +68,9 @@ import {
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
parse,
|
||||
parseISO,
|
||||
set,
|
||||
setDayOfYear,
|
||||
startOfDay,
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
@ -130,9 +129,9 @@ export class PortfolioService {
|
||||
}),
|
||||
this.getDetails({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
impersonationId: userId
|
||||
impersonationId: userId,
|
||||
userId: this.request.user.id
|
||||
})
|
||||
]);
|
||||
|
||||
@ -304,12 +303,16 @@ export class PortfolioService {
|
||||
|
||||
public async getChart({
|
||||
dateRange = 'max',
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -317,7 +320,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -355,28 +358,24 @@ export class PortfolioService {
|
||||
|
||||
public async getDetails({
|
||||
impersonationId,
|
||||
userId,
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
// TODO
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const userCurrency =
|
||||
user.Settings?.settings.baseCurrency ??
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
this.baseCurrency;
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -540,7 +539,11 @@ export class PortfolioService {
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const summary = await this.getSummary({ impersonationId });
|
||||
const summary = await this.getSummary({
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
return {
|
||||
accounts,
|
||||
@ -560,8 +563,9 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({
|
||||
@ -883,12 +887,16 @@ export class PortfolioService {
|
||||
|
||||
public async getPerformance({
|
||||
dateRange = 'max',
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -896,7 +904,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -947,7 +955,9 @@ export class PortfolioService {
|
||||
|
||||
const historicalDataContainer = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||
@ -995,8 +1005,9 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const currency = this.request.user.Settings.settings.baseCurrency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -1010,7 +1021,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -1030,7 +1041,7 @@ export class PortfolioService {
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userId,
|
||||
userCurrency: currency
|
||||
userCurrency
|
||||
});
|
||||
return {
|
||||
rules: {
|
||||
@ -1077,7 +1088,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders).toNumber()
|
||||
this.getFees({ orders, userCurrency }).toNumber()
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
@ -1099,7 +1110,12 @@ export class PortfolioService {
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
}) {
|
||||
const cashPositions: PortfolioDetails['holdings'] = {};
|
||||
const cashPositions: PortfolioDetails['holdings'] = {
|
||||
[userCurrency]: this.getInitialCashPosition({
|
||||
balance: 0,
|
||||
currency: userCurrency
|
||||
})
|
||||
};
|
||||
|
||||
for (const account of cashDetails.accounts) {
|
||||
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
||||
@ -1116,28 +1132,10 @@ export class PortfolioService {
|
||||
cashPositions[account.currency].investment += convertedBalance;
|
||||
cashPositions[account.currency].value += convertedBalance;
|
||||
} else {
|
||||
cashPositions[account.currency] = {
|
||||
allocationCurrent: 0,
|
||||
allocationInvestment: 0,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
currency: account.currency,
|
||||
dataSource: undefined,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: convertedBalance,
|
||||
marketPrice: 0,
|
||||
marketState: 'open',
|
||||
name: account.currency,
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: account.currency,
|
||||
transactionCount: 0,
|
||||
value: convertedBalance
|
||||
};
|
||||
cashPositions[account.currency] = this.getInitialCashPosition({
|
||||
balance: convertedBalance,
|
||||
currency: account.currency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1180,7 +1178,15 @@ export class PortfolioService {
|
||||
return cashPositions;
|
||||
}
|
||||
|
||||
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
private getDividend({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type dividend
|
||||
@ -1193,7 +1199,7 @@ export class PortfolioService {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@ -1202,7 +1208,15 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
private getFees({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
@ -1212,7 +1226,7 @@ export class PortfolioService {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@ -1221,6 +1235,37 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getInitialCashPosition({
|
||||
balance,
|
||||
currency
|
||||
}: {
|
||||
balance: number;
|
||||
currency: string;
|
||||
}): PortfolioPosition {
|
||||
return {
|
||||
currency,
|
||||
allocationCurrent: 0,
|
||||
allocationInvestment: 0,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
dataSource: undefined,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: balance,
|
||||
marketPrice: 0,
|
||||
marketState: 'open',
|
||||
name: currency,
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: currency,
|
||||
transactionCount: 0,
|
||||
value: balance
|
||||
};
|
||||
}
|
||||
|
||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
@ -1262,16 +1307,20 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioSummary> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance({
|
||||
impersonationId
|
||||
impersonationId,
|
||||
userId
|
||||
});
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
@ -1293,11 +1342,11 @@ export class PortfolioService {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const fees = this.getFees({ orders, userCurrency }).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
@ -1565,4 +1614,12 @@ export class PortfolioService {
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
private getUserCurrency(aUser: UserWithSettings) {
|
||||
return (
|
||||
aUser.Settings?.settings.baseCurrency ??
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
this.baseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,10 @@ export class ConfigurationService {
|
||||
this.environmentConfiguration = cleanEnv(process.env, {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
BASE_CURRENCY: str({ default: 'USD' }),
|
||||
BASE_CURRENCY: str({
|
||||
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
|
||||
default: 'USD'
|
||||
}),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({
|
||||
|
23
apps/client-e2e/project.json
Normal file
23
apps/client-e2e/project.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "client-e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
}
|
30
apps/client/ngsw-config.json
Normal file
30
apps/client/ngsw-config.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.html",
|
||||
"/assets/site.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/**",
|
||||
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
209
apps/client/project.json
Normal file
209
apps/client/project.json
Normal file
@ -0,0 +1,209 @@
|
||||
{
|
||||
"name": "client",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"generators": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "site.webmanifest",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true,
|
||||
"serviceWorker": true,
|
||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -145,48 +145,6 @@ const routes: Routes = [
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/activities',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/allocations',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/allocations/allocations-page.module').then(
|
||||
(m) => m.AllocationsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/fire',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/fire/fire-page.module').then(
|
||||
(m) => m.FirePageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/holdings',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/holdings/holdings-page.module').then(
|
||||
(m) => m.HoldingsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/report',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
loadChildren: () =>
|
||||
|
@ -26,6 +26,7 @@ import { GfHeaderModule } from './components/header/header.module';
|
||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageService } from './core/language.service';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
|
||||
export function NgxStripeFactory(): string {
|
||||
return environment.stripePublicKey;
|
||||
@ -50,7 +51,11 @@ export function NgxStripeFactory(): string {
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey),
|
||||
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||
enabled: environment.production,
|
||||
registrationStrategy: 'registerImmediately'
|
||||
})
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
|
@ -4,7 +4,7 @@
|
||||
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline flex-grow-1 mr-2 without-hint"
|
||||
class="compact-with-outline without-hint w-100"
|
||||
>
|
||||
<mat-select formControlName="status">
|
||||
<mat-option></mat-option>
|
||||
@ -15,14 +15,6 @@
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="mt-1"
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
(click)="onDeleteJobs()"
|
||||
>
|
||||
<span i18n>Delete Jobs</span>
|
||||
</button>
|
||||
</form>
|
||||
<table class="gf-table w-100">
|
||||
<thead>
|
||||
@ -35,7 +27,21 @@
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
||||
<th class="mat-header-cell px-1 py-2"></th>
|
||||
<th class="mat-header-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="jobsActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteJobs()">
|
||||
<ng-container i18n>Delete Jobs</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -102,12 +108,12 @@
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
[matMenuTriggerFor]="jobActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onViewData(job.data)">
|
||||
<ng-container i18n>View Data</ng-container>
|
||||
</button>
|
||||
|
@ -150,6 +150,35 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGather7Days() {
|
||||
this.adminService
|
||||
.gather7Days()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
public onGatherMax() {
|
||||
this.adminService
|
||||
.gatherMax()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
public onGatherProfileData() {
|
||||
this.adminService
|
||||
.gatherProfileData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
|
@ -13,10 +13,10 @@
|
||||
<div class="col">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="symbol"
|
||||
matSortDirection="asc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="symbol">
|
||||
@ -101,17 +101,37 @@
|
||||
</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>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onGather7Days()">
|
||||
<ng-container i18n>Gather Recent Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherMax()">
|
||||
<ng-container i18n>Gather All Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherProfileData()">
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -43,7 +42,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private cacheService: CacheService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -162,35 +160,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onGather7Days() {
|
||||
this.adminService
|
||||
.gather7Days()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
public onGatherMax() {
|
||||
this.adminService
|
||||
.gatherMax()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
public onGatherProfileData() {
|
||||
this.adminService
|
||||
.gatherProfileData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
|
@ -27,53 +27,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Data Management</div>
|
||||
<div class="w-50">
|
||||
<div class="overflow-hidden">
|
||||
<div class="mb-2">
|
||||
<button
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onGather7Days()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather Recent Data</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onGatherMax()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather All Data</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="mb-2 mr-2"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onGatherProfileData()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather Profile Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div class="w-50" i18n>Exchange Rates</div>
|
||||
<div class="w-50">
|
||||
|
@ -18,6 +18,7 @@
|
||||
<mat-label i18n>Compare with...</mat-label>
|
||||
<mat-select
|
||||
name="benchmark"
|
||||
[disabled]="user?.subscription?.type === 'Basic'"
|
||||
[value]="benchmark"
|
||||
(selectionChange)="onChangeBenchmark($event.value)"
|
||||
>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||
@ -12,6 +13,7 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfPremiumIndicatorModule,
|
||||
MatSelectModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule
|
||||
|
@ -38,6 +38,7 @@ import {
|
||||
} from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
|
||||
import { last } from 'lodash';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-investment-chart',
|
||||
@ -53,6 +54,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() groupBy: GroupBy;
|
||||
@Input() historicalDataItems: LineChartItem[] = [];
|
||||
@Input() isInPercent = false;
|
||||
@Input() isLoading = false;
|
||||
@Input() locale: string;
|
||||
@Input() range: DateRange = 'max';
|
||||
@Input() savingsRate = 0;
|
||||
@ -60,9 +62,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart<any>;
|
||||
public isLoading = true;
|
||||
|
||||
private data: InvestmentItem[];
|
||||
private investments: InvestmentItem[];
|
||||
private values: LineChartItem[];
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
@ -92,34 +93,49 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
|
||||
// Create a clone
|
||||
this.data = this.benchmarkDataItems.map((item) => Object.assign({}, item));
|
||||
this.investments = this.benchmarkDataItems.map((item) =>
|
||||
Object.assign({}, item)
|
||||
);
|
||||
this.values = this.historicalDataItems.map((item) =>
|
||||
Object.assign({}, item)
|
||||
);
|
||||
|
||||
if (!this.groupBy && this.investments?.length > 0) {
|
||||
let date: string;
|
||||
|
||||
if (!this.groupBy && this.data?.length > 0) {
|
||||
if (this.range === 'max') {
|
||||
// Extend chart by 5% of days in market (before)
|
||||
const firstItem = this.data[0];
|
||||
this.data.unshift({
|
||||
...firstItem,
|
||||
date: format(
|
||||
subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90),
|
||||
DATE_FORMAT
|
||||
date = format(
|
||||
subDays(
|
||||
parseISO(this.investments[0].date),
|
||||
this.daysInMarket * 0.05 || 90
|
||||
),
|
||||
DATE_FORMAT
|
||||
);
|
||||
this.investments.unshift({
|
||||
date,
|
||||
investment: 0
|
||||
});
|
||||
this.values.unshift({
|
||||
date,
|
||||
value: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Extend chart by 5% of days in market (after)
|
||||
const lastItem = this.data[this.data.length - 1];
|
||||
this.data.push({
|
||||
...lastItem,
|
||||
date: format(
|
||||
addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90),
|
||||
DATE_FORMAT
|
||||
)
|
||||
date = format(
|
||||
addDays(
|
||||
parseDate(last(this.investments).date),
|
||||
this.daysInMarket * 0.05 || 90
|
||||
),
|
||||
DATE_FORMAT
|
||||
);
|
||||
this.investments.push({
|
||||
date,
|
||||
investment: last(this.investments).investment
|
||||
});
|
||||
this.values.push({ date, value: last(this.values).value });
|
||||
}
|
||||
|
||||
const data = {
|
||||
@ -131,7 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
data: this.data.map(({ date, investment }) => {
|
||||
data: this.investments.map(({ date, investment }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
y: this.isInPercent ? investment * 100 : investment
|
||||
@ -151,7 +167,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
{
|
||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderWidth: 2,
|
||||
data: this.historicalDataItems.map(({ date, value }) => {
|
||||
data: this.values.map(({ date, value }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
y: this.isInPercent ? value * 100 : value
|
||||
@ -159,7 +175,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}),
|
||||
fill: false,
|
||||
label: $localize`Total Amount`,
|
||||
pointRadius: 0
|
||||
pointRadius: 0,
|
||||
segment: {
|
||||
borderColor: (context: unknown) =>
|
||||
this.isInFuture(
|
||||
context,
|
||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
|
||||
),
|
||||
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -273,8 +297,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private getTooltipPluginConfiguration() {
|
||||
|
@ -22,9 +22,6 @@
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1" i18n>Sell</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<span *ngIf="summary?.totalSell || summary?.totalSell === 0" class="mr-1"
|
||||
>-</span
|
||||
>
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
|
@ -10,7 +10,7 @@
|
||||
></gf-no-transactions-info-indicator>
|
||||
</mat-card>
|
||||
|
||||
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>
|
||||
<gf-rule *ngIf="rules?.length === 0" [isLoading]="true"></gf-rule>
|
||||
<ng-container *ngIf="rules !== null && rules !== undefined">
|
||||
<gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule>
|
||||
</ng-container>
|
||||
|
@ -9,7 +9,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||
})
|
||||
export class RulesComponent {
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() rules: PortfolioReportRule;
|
||||
@Input() rules: PortfolioReportRule[];
|
||||
|
||||
public constructor() {}
|
||||
}
|
||||
|
@ -21,4 +21,4 @@ import { RulesComponent } from './rules.component';
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class RulesModule {}
|
||||
export class GfRulesModule {}
|
||||
|
@ -96,6 +96,20 @@
|
||||
title="Ghostfolio is an independent & bootstrapped business"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!hasPermissionForSubscription"
|
||||
class="d-flex justify-content-center"
|
||||
>
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/ghostfolio"
|
||||
target="_blank"
|
||||
title="Support Ghostfolio"
|
||||
><img
|
||||
class="mb-2"
|
||||
src="../assets/images/button-buy-me-a-coffee.png"
|
||||
width="180"
|
||||
/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,7 +191,7 @@
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
mat-flat-button
|
||||
[routerLink]="['/faq']"
|
||||
>FAQ</a
|
||||
>
|
||||
@ -189,7 +203,7 @@
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
mat-flat-button
|
||||
[routerLink]="['/about', 'changelog']"
|
||||
>Changelog & License</a
|
||||
>
|
||||
@ -198,7 +212,7 @@
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
mat-flat-button
|
||||
[routerLink]="['/about', 'privacy-policy']"
|
||||
>Privacy Policy</a
|
||||
>
|
||||
|
@ -303,6 +303,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onViewModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.dataService
|
||||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -167,29 +167,6 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex mb-2">
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||
<ng-container i18n>View Mode</ng-container>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<div class="align-items-center d-flex overflow-hidden">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="viewMode"
|
||||
[disabled]="!hasPermissionToUpdateViewMode"
|
||||
[value]="user.settings.viewMode"
|
||||
(selectionChange)="onChangeUserSetting('viewMode', $event.value)"
|
||||
>
|
||||
<mat-option value="DEFAULT">Default</mat-option>
|
||||
<mat-option value="ZEN">Zen</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||
<ng-container i18n>Appearance</ng-container>
|
||||
@ -216,6 +193,19 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex mt-4 py-1">
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||
<ng-container i18n>Zen Mode</ng-container>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="user.settings.viewMode === 'ZEN'"
|
||||
[disabled]="!hasPermissionToUpdateViewMode"
|
||||
(change)="onViewModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
|
||||
<div class="pl-1 w-50">
|
||||
|
@ -5,9 +5,9 @@
|
||||
Manage your wealth like a boss
|
||||
</h1>
|
||||
<p class="lead mb-4">
|
||||
Ghostfolio is a privacy-first, open source dashboard to manage your
|
||||
personal finances. Break down your asset allocation, know your net worth
|
||||
and make solid, data-driven investment decisions.
|
||||
Ghostfolio is a privacy-first, open source dashboard for your personal
|
||||
finances. Break down your asset allocation, know your net worth and make
|
||||
solid, data-driven investment decisions.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<a
|
||||
|
@ -114,9 +114,7 @@
|
||||
}
|
||||
|
||||
.outro-inner-container {
|
||||
div {
|
||||
background-image: url('/assets/intro-dark.jpg') !important;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video {
|
||||
|
@ -2,12 +2,12 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { TransactionsPageComponent } from './transactions-page.component';
|
||||
import { ActivitiesPageComponent } from './activities-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: TransactionsPageComponent,
|
||||
component: ActivitiesPageComponent,
|
||||
path: '',
|
||||
title: $localize`Activities`
|
||||
}
|
||||
@ -17,4 +17,4 @@ const routes: Routes = [
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class TransactionsPageRoutingModule {}
|
||||
export class ActivitiesPageRoutingModule {}
|
@ -1,6 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
@ -10,35 +9,34 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { isArray } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
|
||||
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
|
||||
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
|
||||
import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
|
||||
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-transactions-page',
|
||||
styleUrls: ['./transactions-page.scss'],
|
||||
templateUrl: './transactions-page.html'
|
||||
selector: 'gf-activities-page',
|
||||
styleUrls: ['./activities-page.scss'],
|
||||
templateUrl: './activities-page.html'
|
||||
})
|
||||
export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
public activities: Activity[];
|
||||
public defaultAccountId: string;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public hasPermissionToDeleteOrder: boolean;
|
||||
public hasPermissionToImportOrders: boolean;
|
||||
public hasPermissionToCreateActivity: boolean;
|
||||
public hasPermissionToDeleteActivity: boolean;
|
||||
public hasPermissionToImportActivities: boolean;
|
||||
public routeQueryParams: Subscription;
|
||||
public user: User;
|
||||
|
||||
@ -51,24 +49,22 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
private dialog: MatDialog,
|
||||
private icsService: IcsService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private importTransactionsService: ImportTransactionsService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private snackBar: MatSnackBar,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog']) {
|
||||
this.openCreateTransactionDialog();
|
||||
this.openCreateActivityDialog();
|
||||
} else if (params['editDialog']) {
|
||||
if (this.activities) {
|
||||
const transaction = this.activities.find(({ id }) => {
|
||||
return id === params['transactionId'];
|
||||
const activity = this.activities.find(({ id }) => {
|
||||
return id === params['activityId'];
|
||||
});
|
||||
|
||||
this.openUpdateTransactionDialog(transaction);
|
||||
this.openUpdateActivityDialog(activity);
|
||||
} else {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
}
|
||||
@ -96,7 +92,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
|
||||
this.hasPermissionToImportOrders =
|
||||
this.hasPermissionToImportActivities =
|
||||
hasPermission(globalPermissions, permissions.enableImport) &&
|
||||
!this.hasImpersonationId;
|
||||
});
|
||||
@ -121,7 +117,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) {
|
||||
if (
|
||||
this.hasPermissionToCreateActivity &&
|
||||
this.activities?.length <= 0
|
||||
) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
@ -129,11 +128,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onCloneTransaction(aActivity: Activity) {
|
||||
this.openCreateTransactionDialog(aActivity);
|
||||
public onCloneActivity(aActivity: Activity) {
|
||||
this.openCreateActivityDialog(aActivity);
|
||||
}
|
||||
|
||||
public onDeleteTransaction(aId: string) {
|
||||
public onDeleteActivity(aId: string) {
|
||||
this.dataService
|
||||
.deleteOrder(aId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -183,98 +182,30 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
const input = document.createElement('input');
|
||||
input.accept = 'application/JSON, .csv';
|
||||
input.type = 'file';
|
||||
const dialogRef = this.dialog.open(ImportActivitiesDialog, {
|
||||
data: <ImportActivitiesDialogParams>{
|
||||
deviceType: this.deviceType,
|
||||
user: this.user
|
||||
},
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
input.onchange = (event) => {
|
||||
this.snackBar.open('⏳' + $localize`Importing data...`);
|
||||
|
||||
// Getting the file reference
|
||||
const file = (event.target as HTMLInputElement).files[0];
|
||||
|
||||
// Setting up the reader
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
|
||||
reader.onload = async (readerEvent) => {
|
||||
const fileContent = readerEvent.target.result as string;
|
||||
|
||||
try {
|
||||
if (file.name.endsWith('.json')) {
|
||||
const content = JSON.parse(fileContent);
|
||||
|
||||
if (!isArray(content.activities)) {
|
||||
if (isArray(content.orders)) {
|
||||
this.handleImportError({
|
||||
activities: [],
|
||||
error: {
|
||||
error: {
|
||||
message: [`orders needs to be renamed to activities`]
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.importTransactionsService.importJson({
|
||||
content: content.activities
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({ error, activities: content.activities });
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
try {
|
||||
await this.importTransactionsService.importCsv({
|
||||
fileContent,
|
||||
userAccounts: this.user.accounts
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: error?.activities ?? [],
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: [],
|
||||
error: { error: { message: ['Unexpected format'] } }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
input.click();
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.fetchActivities();
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateTransaction(aTransaction: OrderModel) {
|
||||
public onUpdateActivity(aActivity: OrderModel) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { editDialog: true, transactionId: aTransaction.id }
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
});
|
||||
}
|
||||
|
||||
public openUpdateTransactionDialog(activity: Activity): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
public openUpdateActivityDialog(activity: Activity): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
|
||||
data: {
|
||||
activity,
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
@ -312,41 +243,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private handleImportError({
|
||||
activities,
|
||||
error
|
||||
}: {
|
||||
activities: any[];
|
||||
error: any;
|
||||
}) {
|
||||
this.snackBar.dismiss();
|
||||
|
||||
this.dialog.open(ImportTransactionDialog, {
|
||||
data: {
|
||||
activities,
|
||||
deviceType: this.deviceType,
|
||||
messages: error?.error?.message
|
||||
},
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
}
|
||||
|
||||
private handleImportSuccess() {
|
||||
this.fetchActivities();
|
||||
|
||||
this.snackBar.open('✅' + $localize`Import has been completed`, undefined, {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
private openCreateTransactionDialog(aActivity?: Activity): void {
|
||||
private openCreateActivityDialog(aActivity?: Activity): void {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.updateUser(user);
|
||||
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
|
||||
data: {
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
return account.accountType === 'SECURITIES';
|
||||
@ -434,11 +338,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.hasPermissionToCreateActivity = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
this.hasPermissionToDeleteOrder = hasPermission(
|
||||
this.hasPermissionToDeleteActivity = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteOrder
|
||||
);
|
@ -6,14 +6,14 @@
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToImportActivities]="hasPermissionToImportOrders"
|
||||
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView"
|
||||
(activityDeleted)="onDeleteTransaction($event)"
|
||||
(activityToClone)="onCloneTransaction($event)"
|
||||
(activityToUpdate)="onUpdateTransaction($event)"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
|
||||
(activityDeleted)="onDeleteActivity($event)"
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(export)="onExport($event)"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
@ -22,15 +22,15 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!hasImpersonationId && hasPermissionToCreateOrder && !user.settings.isRestrictedView"
|
||||
*ngIf="!hasImpersonationId && hasPermissionToCreateActivity && !user.settings.isRestrictedView"
|
||||
class="fab-container"
|
||||
>
|
||||
<a
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ createDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
@ -0,0 +1,29 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
||||
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
|
||||
import { ActivitiesPageComponent } from './activities-page.component';
|
||||
import { GfCreateOrUpdateActivityDialogModule } from './create-or-update-activity-dialog/create-or-update-activity-dialog.module';
|
||||
import { GfImportActivitiesDialogModule } from './import-activities-dialog/import-activities-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ActivitiesPageComponent],
|
||||
imports: [
|
||||
ActivitiesPageRoutingModule,
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfCreateOrUpdateActivityDialogModule,
|
||||
GfImportActivitiesDialogModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [ImportActivitiesService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class ActivitiesPageModule {}
|
@ -14,6 +14,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, AssetSubClass, Type } from '@prisma/client';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
@ -27,21 +28,25 @@ import {
|
||||
takeUntil
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
|
||||
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-or-update-transaction-dialog',
|
||||
selector: 'gf-create-or-update-activity-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./create-or-update-transaction-dialog.scss'],
|
||||
templateUrl: 'create-or-update-transaction-dialog.html'
|
||||
styleUrls: ['./create-or-update-activity-dialog.scss'],
|
||||
templateUrl: 'create-or-update-activity-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
@ViewChild('autocomplete') autocomplete;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public assetClasses = Object.keys(AssetClass);
|
||||
public assetSubClasses = Object.keys(AssetSubClass);
|
||||
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
return { id: assetClass, label: translate(assetClass) };
|
||||
});
|
||||
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
|
||||
return { id: assetSubClass, label: translate(assetSubClass) };
|
||||
});
|
||||
public currencies: string[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public filteredLookupItems: LookupItem[];
|
||||
@ -55,10 +60,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
|
||||
private dataService: DataService,
|
||||
private dateAdapter: DateAdapter<any>,
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateActivityDialog>,
|
||||
private formBuilder: FormBuilder,
|
||||
@Inject(MAT_DATE_LOCALE) private locale: string
|
||||
) {}
|
@ -4,17 +4,17 @@
|
||||
(keyup.enter)="activityForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
|
||||
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1>
|
||||
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
|
||||
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
<mat-option value="BUY" i18n>BUY</mat-option>
|
||||
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
|
||||
<mat-option value="ITEM" i18n>ITEM</mat-option>
|
||||
<mat-option value="SELL" i18n>SELL</mat-option>
|
||||
<mat-option i18n value="BUY">BUY</mat-option>
|
||||
<mat-option i18n value="DIVIDEND">DIVIDEND</mat-option>
|
||||
<mat-option i18n value="ITEM">ITEM</mat-option>
|
||||
<mat-option i18n value="SELL">SELL</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@ -156,8 +156,8 @@
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetClass of assetClasses"
|
||||
[value]="assetClass"
|
||||
>{{ assetClass }}</mat-option
|
||||
[value]="assetClass.id"
|
||||
>{{ assetClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@ -171,8 +171,8 @@
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetSubClass of assetSubClasses"
|
||||
[value]="assetSubClass"
|
||||
>{{ assetSubClass }}</mat-option
|
||||
[value]="assetSubClass.id"
|
||||
>{{ assetSubClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
@ -13,10 +13,10 @@ import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';
|
||||
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CreateOrUpdateTransactionDialog],
|
||||
declarations: [CreateOrUpdateActivityDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfSymbolModule,
|
||||
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfCreateOrUpdateTransactionDialogModule {}
|
||||
export class GfCreateOrUpdateActivityDialogModule {}
|
@ -2,7 +2,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export interface CreateOrUpdateTransactionDialogParams {
|
||||
export interface CreateOrUpdateActivityDialogParams {
|
||||
accountId: string;
|
||||
accounts: Account[];
|
||||
activity: Activity;
|
@ -0,0 +1,176 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { isArray } from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-import-activities-dialog',
|
||||
styleUrls: ['./import-activities-dialog.scss'],
|
||||
templateUrl: 'import-activities-dialog.html'
|
||||
})
|
||||
export class ImportActivitiesDialog implements OnDestroy {
|
||||
public details: any[] = [];
|
||||
public errorMessages: string[] = [];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
||||
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
|
||||
private importActivitiesService: ImportActivitiesService,
|
||||
private snackBar: MatSnackBar
|
||||
) {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
const input = document.createElement('input');
|
||||
input.accept = 'application/JSON, .csv';
|
||||
input.type = 'file';
|
||||
|
||||
input.onchange = (event) => {
|
||||
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
||||
|
||||
// Getting the file reference
|
||||
const file = (event.target as HTMLInputElement).files[0];
|
||||
|
||||
// Setting up the reader
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
|
||||
reader.onload = async (readerEvent) => {
|
||||
const fileContent = readerEvent.target.result as string;
|
||||
|
||||
console.log(fileContent);
|
||||
|
||||
try {
|
||||
if (file.name.endsWith('.json')) {
|
||||
const content = JSON.parse(fileContent);
|
||||
|
||||
if (!isArray(content.activities)) {
|
||||
if (isArray(content.orders)) {
|
||||
this.handleImportError({
|
||||
activities: [],
|
||||
error: {
|
||||
error: {
|
||||
message: [`orders needs to be renamed to activities`]
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.importActivitiesService.importJson({
|
||||
content: content.activities
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({ error, activities: content.activities });
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
try {
|
||||
await this.importActivitiesService.importCsv({
|
||||
fileContent,
|
||||
userAccounts: this.data.user.accounts
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: error?.activities ?? [],
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: [],
|
||||
error: { error: { message: ['Unexpected format'] } }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
public onReset() {
|
||||
this.details = [];
|
||||
this.errorMessages = [];
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private handleImportError({
|
||||
activities,
|
||||
error
|
||||
}: {
|
||||
activities: any[];
|
||||
error: any;
|
||||
}) {
|
||||
this.snackBar.dismiss();
|
||||
|
||||
this.errorMessages = error?.error?.message;
|
||||
|
||||
for (const message of this.errorMessages) {
|
||||
if (message.includes('activities.')) {
|
||||
let [index] = message.split(' ');
|
||||
index = index.replace('activities.', '');
|
||||
[index] = index.split('.');
|
||||
|
||||
this.details.push(activities[index]);
|
||||
} else {
|
||||
this.details.push('');
|
||||
}
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
private handleImportSuccess() {
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Import has been completed`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
[deviceType]="data.deviceType"
|
||||
[title]="errorMessages.length === 0 ? 'Import Activities' : 'Import Activities Error'"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-header>
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<ng-container *ngIf="errorMessages.length === 0">
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<button
|
||||
class="py-3"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
(click)="onImport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Choose File</span>
|
||||
</button>
|
||||
<p class="mb-0 mt-4 text-center">
|
||||
<span class="mr-1" i18n>The following file formats are supported:</span>
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
|
||||
target="_blank"
|
||||
>CSV</a
|
||||
>
|
||||
<span class="mx-1" i18n>or</span>
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
|
||||
target="_blank"
|
||||
>JSON</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="errorMessages.length > 0">
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel
|
||||
*ngFor="let message of errorMessages; let i = index"
|
||||
[disabled]="!details[i]"
|
||||
>
|
||||
<mat-expansion-panel-header class="pl-1">
|
||||
<mat-panel-title>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex mr-2">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
</div>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<pre
|
||||
*ngIf="details[i]"
|
||||
class="m-0"
|
||||
><code>{{ details[i] | json }}</code></pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="mt-2">
|
||||
<button mat-button (click)="onReset()">
|
||||
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
|
||||
<span i18n>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-footer>
|
@ -6,10 +6,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
|
||||
import { ImportTransactionDialog } from './import-transaction-dialog.component';
|
||||
import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ImportTransactionDialog],
|
||||
declarations: [ImportActivitiesDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfDialogFooterModule,
|
||||
@ -20,4 +20,4 @@ import { ImportTransactionDialog } from './import-transaction-dialog.component';
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfImportTransactionDialogModule {}
|
||||
export class GfImportActivitiesDialogModule {}
|
@ -1,6 +1,10 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
}
|
||||
|
||||
.mat-expansion-panel {
|
||||
background: none;
|
||||
box-shadow: none;
|
@ -0,0 +1,6 @@
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface ImportActivitiesDialogParams {
|
||||
deviceType: string;
|
||||
user: User;
|
||||
}
|
@ -19,6 +19,7 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Market, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { Account, AssetClass, DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
@ -174,7 +175,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public investments: InvestmentItem[];
|
||||
public investmentsByMonth: InvestmentItem[];
|
||||
public isLoadingBenchmarkComparator: boolean;
|
||||
public isLoadingInvestmentChart: boolean;
|
||||
public mode: GroupBy = 'month';
|
||||
public modeOptions: ToggleOption[] = [
|
||||
{ label: $localize`Monthly`, value: 'month' }
|
||||
@ -125,6 +126,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
private update() {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
this.isLoadingInvestmentChart = true;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({
|
||||
@ -156,6 +158,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.isLoadingInvestmentChart = false;
|
||||
|
||||
this.updateBenchmarkDataItems();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -125,6 +125,7 @@
|
||||
[daysInMarket]="daysInMarket"
|
||||
[historicalDataItems]="performanceDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[locale]="user?.settings?.locale"
|
||||
[range]="user?.settings?.dateRange"
|
||||
></gf-investment-chart>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import Big from 'big.js';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -15,8 +15,12 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './fire-page.html'
|
||||
})
|
||||
export class FirePageComponent implements OnDestroy, OnInit {
|
||||
public accountClusterRiskRules: PortfolioReportRule[];
|
||||
public currencyClusterRiskRules: PortfolioReportRule[];
|
||||
public deviceType: string;
|
||||
public feeRules: PortfolioReportRule[];
|
||||
public fireWealth: Big;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public isLoading = false;
|
||||
public user: User;
|
||||
@ -53,12 +57,30 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioReport()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioReport) => {
|
||||
this.accountClusterRiskRules =
|
||||
portfolioReport.rules['accountClusterRisk'] || null;
|
||||
this.currencyClusterRiskRules =
|
||||
portfolioReport.rules['currencyClusterRisk'] || null;
|
||||
this.feeRules = portfolioReport.rules['fees'] || null;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
|
@ -3,7 +3,13 @@
|
||||
<div class="col-lg">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
|
||||
<div>
|
||||
<h4 class="mb-3" i18n>Calculator</h4>
|
||||
<h4 class="align-items-center d-flex mb-3">
|
||||
<span i18n>Calculator</span
|
||||
><gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<gf-fire-calculator
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
@ -18,7 +24,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 i18n>4% Rule</h4>
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>4% Rule</span
|
||||
><gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<div *ngIf="isLoading">
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
@ -67,3 +79,61 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="align-items-center d-flex justify-content-center mb-3">
|
||||
X-ray
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
||||
risks in your portfolio.
|
||||
<span class="d-none"
|
||||
>It will be highly configurable in the future: activate / deactivate
|
||||
rules and customize the thresholds to match your personal investment
|
||||
style.</span
|
||||
>
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<h4 class="align-items-center d-flex m-0">
|
||||
<span>Currency Cluster Risks</span
|
||||
><gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="currencyClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h4 class="align-items-center d-flex m-0">
|
||||
<span>Account Cluster Risks</span
|
||||
><gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="accountClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="align-items-center d-flex m-0">
|
||||
<span>Fees</span
|
||||
><gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="feeRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
|
||||
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -13,6 +15,8 @@ import { FirePageComponent } from './fire-page.component';
|
||||
CommonModule,
|
||||
FirePageRoutingModule,
|
||||
GfFireCalculatorModule,
|
||||
GfPremiumIndicatorModule,
|
||||
GfRulesModule,
|
||||
GfValueModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
@ -130,7 +131,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
@ -7,9 +7,45 @@ import { PortfolioPageComponent } from './portfolio-page.component';
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'analysis', pathMatch: 'full' },
|
||||
{
|
||||
path: 'analysis',
|
||||
loadChildren: () =>
|
||||
import('./analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'holdings',
|
||||
loadChildren: () =>
|
||||
import('./holdings/holdings-page.module').then(
|
||||
(m) => m.HoldingsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'activities',
|
||||
loadChildren: () =>
|
||||
import('./activities/activities-page.module').then(
|
||||
(m) => m.ActivitiesPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'allocations',
|
||||
loadChildren: () =>
|
||||
import('./allocations/allocations-page.module').then(
|
||||
(m) => m.AllocationsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'fire',
|
||||
loadChildren: () =>
|
||||
import('./fire/fire-page.module').then((m) => m.FirePageModule)
|
||||
}
|
||||
],
|
||||
component: PortfolioPageComponent,
|
||||
path: '',
|
||||
title: 'Portfolio'
|
||||
title: $localize`Portfolio`
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,19 +1,30 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
HostBinding,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-portfolio-page',
|
||||
styleUrls: ['./portfolio-page.scss'],
|
||||
templateUrl: './portfolio-page.html'
|
||||
})
|
||||
export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
@HostBinding('class.with-info-message') get getHasMessage() {
|
||||
return this.hasMessage;
|
||||
}
|
||||
|
||||
public hasMessage: boolean;
|
||||
public info: InfoItem;
|
||||
public tabs: { iconName: string; path: string }[] = [];
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -23,26 +34,34 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {
|
||||
const { globalPermissions } = this.dataService.fetchInfo();
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.tabs = [
|
||||
{ iconName: 'analytics-outline', path: 'analysis' },
|
||||
{ iconName: 'wallet-outline', path: 'holdings' },
|
||||
{ iconName: 'swap-horizontal-outline', path: 'activities' },
|
||||
{ iconName: 'pie-chart-outline', path: 'allocations' },
|
||||
{ iconName: 'calculator-outline', path: 'fire' }
|
||||
];
|
||||
this.user = state.user;
|
||||
|
||||
this.hasMessage =
|
||||
hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.createUserAccount
|
||||
) || !!this.info.systemMessage;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -1,134 +1,15 @@
|
||||
<div class="container">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 i18n>Holdings</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Get an overview of your current holdings.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a
|
||||
color="primary"
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'holdings']"
|
||||
>
|
||||
<span i18n>Open Holdings</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 i18n>Activities</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Manage your activities: stocks, ETFs, cryptocurrencies, dividend, and
|
||||
valuables.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a
|
||||
color="primary"
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>
|
||||
<span i18n>Open Activities</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Allocations</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Check the allocations of your portfolio by account, asset class,
|
||||
currency, sector and region.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a
|
||||
color="primary"
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'allocations']"
|
||||
>
|
||||
<span i18n>Open Allocations</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Analysis</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Ghostfolio Analysis visualizes your portfolio and shows your top and
|
||||
bottom performers.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a
|
||||
color="primary"
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'analysis']"
|
||||
>
|
||||
<span i18n>Open Analysis</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>X-ray</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
||||
risks in your portfolio.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a color="primary" mat-button [routerLink]="['/portfolio', 'report']">
|
||||
<span i18n>Open X-ray</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>FIRE</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</h4>
|
||||
<div class="flex-grow-1" i18n>
|
||||
Ghostfolio FIRE calculates metrics for the
|
||||
<i>Financial Independence, Retire Early</i> lifestyle.
|
||||
</div>
|
||||
<div class="mt-2 text-right">
|
||||
<a color="primary" mat-button [routerLink]="['/portfolio', 'fire']">
|
||||
<span i18n>Open FIRE</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<nav mat-align-tabs="center" mat-tab-nav-bar>
|
||||
<a
|
||||
#rla="routerLinkActive"
|
||||
*ngFor="let tab of tabs"
|
||||
class="px-3"
|
||||
mat-tab-link
|
||||
routerLinkActive
|
||||
[active]="rla.isActive"
|
||||
[routerLink]="tab.path"
|
||||
>
|
||||
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
|
||||
</a>
|
||||
</nav>
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
import { PortfolioPageRoutingModule } from './portfolio-page-routing.module';
|
||||
import { PortfolioPageComponent } from './portfolio-page.component';
|
||||
@ -12,9 +10,7 @@ import { PortfolioPageComponent } from './portfolio-page.component';
|
||||
declarations: [PortfolioPageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPremiumIndicatorModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatTabsModule,
|
||||
PortfolioPageRoutingModule,
|
||||
RouterModule
|
||||
],
|
||||
|
@ -1,10 +1,46 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 5rem);
|
||||
overflow-y: auto;
|
||||
|
||||
.mat-card {
|
||||
.mat-button-disabled {
|
||||
pointer-events: none;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
|
||||
::ng-deep {
|
||||
gf-activities-page,
|
||||
gf-allocations-page,
|
||||
gf-analysis-page,
|
||||
gf-holdings-page,
|
||||
gf-fire-page {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mat-tab-header {
|
||||
border-bottom: 0;
|
||||
|
||||
.mat-ink-bar {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.mat-tab-label-active {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mat-tab-link {
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { ReportPageComponent } from './report-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: ReportPageComponent,
|
||||
path: '',
|
||||
title: 'X-ray'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ReportPageRoutingModule {}
|
@ -1,64 +0,0 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-report-page',
|
||||
styleUrls: ['./report-page.scss'],
|
||||
templateUrl: './report-page.html'
|
||||
})
|
||||
export class ReportPageComponent implements OnDestroy, OnInit {
|
||||
public accountClusterRiskRules: PortfolioReportRule[];
|
||||
public currencyClusterRiskRules: PortfolioReportRule[];
|
||||
public feeRules: PortfolioReportRule[];
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.dataService
|
||||
.fetchPortfolioReport()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioReport) => {
|
||||
this.accountClusterRiskRules =
|
||||
portfolioReport.rules['accountClusterRisk'] || null;
|
||||
this.currencyClusterRiskRules =
|
||||
portfolioReport.rules['currencyClusterRisk'] || null;
|
||||
this.feeRules = portfolioReport.rules['fees'] || null;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="align-items-center d-flex justify-content-center mb-3">
|
||||
X-ray
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
||||
risks in your portfolio.
|
||||
<span class="d-none"
|
||||
>It will be highly configurable in the future: activate / deactivate
|
||||
rules and customize the thresholds to match your personal investment
|
||||
style.</span
|
||||
>
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<h4 class="m-0">Currency Cluster Risks</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="currencyClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h4 class="m-0">Account Cluster Risks</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="accountClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="m-0">Fees</h4>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="feeRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,13 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { RulesModule } from '@ghostfolio/client/components/rules/rules.module';
|
||||
|
||||
import { ReportPageRoutingModule } from './report-page-routing.module';
|
||||
import { ReportPageComponent } from './report-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ReportPageComponent],
|
||||
imports: [CommonModule, ReportPageRoutingModule, RulesModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class ReportPageModule {}
|
@ -1,8 +0,0 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { ImportTransactionDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-import-transaction-dialog',
|
||||
styleUrls: ['./import-transaction-dialog.scss'],
|
||||
templateUrl: 'import-transaction-dialog.html'
|
||||
})
|
||||
export class ImportTransactionDialog implements OnDestroy {
|
||||
public details: any[] = [];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
|
||||
public dialogRef: MatDialogRef<ImportTransactionDialog>
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
for (const message of this.data.messages) {
|
||||
if (message.includes('activities.')) {
|
||||
let [index] = message.split(' ');
|
||||
index = index.replace('activities.', '');
|
||||
[index] = index.split('.');
|
||||
|
||||
this.details.push(this.data.activities[index]);
|
||||
} else {
|
||||
this.details.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
title="Import Activities Error"
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-header>
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel
|
||||
*ngFor="let message of data.messages; let i = index"
|
||||
[disabled]="!details[i]"
|
||||
>
|
||||
<mat-expansion-panel-header class="pl-1">
|
||||
<mat-panel-title>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex mr-2">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
</div>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<pre
|
||||
*ngIf="details[i]"
|
||||
class="m-0"
|
||||
><code>{{ details[i] | json }}</code></pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onCancel()"
|
||||
></gf-dialog-footer>
|
@ -1,5 +0,0 @@
|
||||
export interface ImportTransactionDialogParams {
|
||||
activities: any[];
|
||||
deviceType: string;
|
||||
messages: string[];
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
||||
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
|
||||
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
|
||||
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
|
||||
import { TransactionsPageComponent } from './transactions-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [TransactionsPageComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfCreateOrUpdateTransactionDialogModule,
|
||||
GfImportTransactionDialogModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
RouterModule,
|
||||
TransactionsPageRoutingModule
|
||||
],
|
||||
providers: [ImportTransactionsService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class TransactionsPageModule {}
|
@ -12,7 +12,8 @@
|
||||
</p>
|
||||
<p *ngIf="user?.subscription?.type === 'Basic'">
|
||||
If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>,
|
||||
<i>Interactive Brokers</i>, <i>Swissquote</i>, or <i>VIAC</i>, please
|
||||
<i>Interactive Brokers</i>, <i>Swissquote</i>, <i>VIAC</i>, or
|
||||
<i>Zak</i>, please
|
||||
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..."
|
||||
>contact us</a
|
||||
>
|
||||
@ -26,7 +27,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<div class="col-xs-12 col-lg-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4>Open Source</h4>
|
||||
@ -72,11 +73,20 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Self-hosted.</p>
|
||||
<p>Self-hosted, update manually.</p>
|
||||
<p class="h5 text-right">Free</p>
|
||||
<div
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="d-none d-lg-block hidden mt-3 text-center"
|
||||
>
|
||||
<a color="primary" mat-flat-button> </a>
|
||||
<p class="m-0 text-muted">
|
||||
<small> </small>
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<div class="col-xs-12 col-lg-4 mb-3">
|
||||
<mat-card
|
||||
class="d-flex flex-column h-100"
|
||||
[ngClass]="{ 'active': user?.subscription?.type === 'Basic' }"
|
||||
@ -124,9 +134,18 @@
|
||||
</div>
|
||||
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
|
||||
<p class="h5 text-right">Free</p>
|
||||
<div
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="d-none d-lg-block hidden mt-3 text-center"
|
||||
>
|
||||
<a color="primary" mat-flat-button> </a>
|
||||
<p class="m-0 text-muted">
|
||||
<small> </small>
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<div class="col-xs-12 col-lg-4 mb-3">
|
||||
<mat-card
|
||||
class="d-flex flex-column h-100"
|
||||
[ngClass]="{ 'active': user?.subscription?.type === 'Premium' }"
|
||||
@ -198,24 +217,28 @@
|
||||
> <span>per year</span></span
|
||||
>
|
||||
</p>
|
||||
<div
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="mt-3 text-center"
|
||||
>
|
||||
<a color="primary" mat-flat-button [routerLink]="['/account']">
|
||||
Upgrade Plan
|
||||
</a>
|
||||
<p class="m-0 text-muted">
|
||||
<small>One-time payment, no auto-renewal.</small>
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="user?.subscription?.type === 'Basic'" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" mat-flat-button [routerLink]="['/account']">
|
||||
Upgrade Plan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" mat-flat-button [routerLink]="['/register']">
|
||||
Get Started
|
||||
</a>
|
||||
<p class="text-muted"><small>It's free</small></p>
|
||||
<p class="m-0 text-muted"><small>It’s free.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,6 +12,7 @@
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&:hover,
|
||||
&.active {
|
||||
border-color: rgba(var(--palette-primary-500), 1);
|
||||
box-shadow: 0 0 0 1px rgba(var(--palette-primary-500), 1);
|
||||
|
@ -32,8 +32,6 @@
|
||||
}
|
||||
|
||||
.intro-container {
|
||||
.intro {
|
||||
background-image: url('/assets/intro-dark.jpg') !important;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,13 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
||||
import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Order as OrderModel
|
||||
} from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { cloneDeep, groupBy } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
@ -232,6 +238,19 @@ export class DataService {
|
||||
response.summary.firstOrderDate
|
||||
);
|
||||
}
|
||||
|
||||
if (response.holdings) {
|
||||
for (const symbol of Object.keys(response.holdings)) {
|
||||
response.holdings[symbol].assetClass = translate(
|
||||
response.holdings[symbol].assetClass
|
||||
);
|
||||
|
||||
response.holdings[symbol].assetSubClass = translate(
|
||||
response.holdings[symbol].assetSubClass
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
);
|
||||
@ -285,6 +304,20 @@ export class DataService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.SymbolProfile) {
|
||||
if (data.SymbolProfile.assetClass) {
|
||||
data.SymbolProfile.assetClass = <AssetClass>(
|
||||
translate(data.SymbolProfile.assetClass)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.SymbolProfile.assetSubClass) {
|
||||
data.SymbolProfile.assetSubClass = <AssetSubClass>(
|
||||
translate(data.SymbolProfile.assetSubClass)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
);
|
||||
|
@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Account, DataSource, Type } from '@prisma/client';
|
||||
import { parse } from 'date-fns';
|
||||
import { isMatch, parse, parseISO } from 'date-fns';
|
||||
import { isFinite } from 'lodash';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { EMPTY } from 'rxjs';
|
||||
@ -11,7 +11,7 @@ import { catchError } from 'rxjs/operators';
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ImportTransactionsService {
|
||||
export class ImportActivitiesService {
|
||||
private static ACCOUNT_KEYS = ['account', 'accountid'];
|
||||
private static CURRENCY_KEYS = ['ccy', 'currency'];
|
||||
private static DATA_SOURCE_KEYS = ['datasource'];
|
||||
@ -90,7 +90,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.ACCOUNT_KEYS) {
|
||||
for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
|
||||
if (item[key]) {
|
||||
return userAccounts.find((account) => {
|
||||
return (
|
||||
@ -115,7 +115,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.CURRENCY_KEYS) {
|
||||
for (const key of ImportActivitiesService.CURRENCY_KEYS) {
|
||||
if (item[key]) {
|
||||
return item[key];
|
||||
}
|
||||
@ -130,7 +130,7 @@ export class ImportTransactionsService {
|
||||
private parseDataSource({ item }: { item: any }) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.DATA_SOURCE_KEYS) {
|
||||
for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
|
||||
if (item[key]) {
|
||||
return DataSource[item[key].toUpperCase()];
|
||||
}
|
||||
@ -151,15 +151,17 @@ export class ImportTransactionsService {
|
||||
item = this.lowercaseKeys(item);
|
||||
let date: string;
|
||||
|
||||
for (const key of ImportTransactionsService.DATE_KEYS) {
|
||||
for (const key of ImportActivitiesService.DATE_KEYS) {
|
||||
if (item[key]) {
|
||||
try {
|
||||
if (isMatch(item[key], 'dd-MM-yyyy')) {
|
||||
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
} else if (isMatch(item[key], 'dd/MM/yyyy')) {
|
||||
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
date = parseISO(item[key]).toISOString();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (date) {
|
||||
return date;
|
||||
@ -184,7 +186,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.FEE_KEYS) {
|
||||
for (const key of ImportActivitiesService.FEE_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
}
|
||||
@ -207,7 +209,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.QUANTITY_KEYS) {
|
||||
for (const key of ImportActivitiesService.QUANTITY_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
}
|
||||
@ -230,7 +232,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.SYMBOL_KEYS) {
|
||||
for (const key of ImportActivitiesService.SYMBOL_KEYS) {
|
||||
if (item[key]) {
|
||||
return item[key];
|
||||
}
|
||||
@ -253,7 +255,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.TYPE_KEYS) {
|
||||
for (const key of ImportActivitiesService.TYPE_KEYS) {
|
||||
if (item[key]) {
|
||||
switch (item[key].toLowerCase()) {
|
||||
case 'buy':
|
||||
@ -287,7 +289,7 @@ export class ImportTransactionsService {
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) {
|
||||
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
}
|
BIN
apps/client/src/assets/images/button-buy-me-a-coffee.png
Normal file
BIN
apps/client/src/assets/images/button-buy-me-a-coffee.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
Before Width: | Height: | Size: 152 KiB |
@ -1,24 +1,25 @@
|
||||
{
|
||||
"background_color": "transparent",
|
||||
"background_color": "#FFFFFF",
|
||||
"categories": ["finance", "utilities"],
|
||||
"description": "Open Source Wealth Management Software",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"sizes": "192x192",
|
||||
"src": "/en/assets/android-chrome-192x192.png",
|
||||
"type": "image/png"
|
||||
"src": "/assets/android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"sizes": "512x512",
|
||||
"src": "/en/assets/android-chrome-512x512.png",
|
||||
"src": "/assets/android-chrome-512x512.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"name": "Ghostfolio",
|
||||
"orientation": "portrait",
|
||||
"short_name": "Ghostfolio",
|
||||
"start_url": "https://www.ghostfol.io",
|
||||
"start_url": "/en/",
|
||||
"theme_color": "#FFFFFF",
|
||||
"url": "https://www.ghostfol.io"
|
||||
}
|
||||
|
@ -28,6 +28,7 @@
|
||||
content="initial-scale=1, viewport-fit=cover, width=device-width"
|
||||
name="viewport"
|
||||
/>
|
||||
<meta content="#FFFFFF" name="theme-color" />
|
||||
<meta content="" property="og:description" />
|
||||
<meta
|
||||
content="Ghostfolio – Open Source Wealth Management Software"
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2854
apps/client/src/locales/messages.pt.xlf
Normal file
2854
apps/client/src/locales/messages.pt.xlf
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@ $mat-css-light-theme-selector: '.is-light-theme';
|
||||
}
|
||||
|
||||
:root {
|
||||
--dark-background: rgb(39, 39, 39);
|
||||
--dark-background: rgb(25, 25, 25);
|
||||
--font-family-sans-serif: Roboto, 'Helvetica Neue', sans-serif;
|
||||
--light-background: rgb(255, 255, 255);
|
||||
}
|
||||
@ -25,7 +25,6 @@ $mat-css-light-theme-selector: '.is-light-theme';
|
||||
body {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
margin: 0;
|
||||
margin-bottom: 5rem;
|
||||
min-height: 100%;
|
||||
|
||||
a {
|
||||
@ -90,6 +89,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.mat-paginator {
|
||||
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
|
||||
}
|
||||
|
||||
.svgMap-tooltip {
|
||||
background: var(--dark-background);
|
||||
|
||||
@ -102,6 +105,15 @@ body {
|
||||
.mat-select-placeholder {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
|
||||
&.mat-select-disabled {
|
||||
.mat-select-placeholder {
|
||||
color: rgba(
|
||||
var(--palette-foreground-disabled-text-dark),
|
||||
var(--palette-foreground-disabled-text-dark-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,6 +165,10 @@ ngx-skeleton-loader {
|
||||
@include gf-table;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-weight: unset;
|
||||
}
|
||||
@ -211,6 +227,14 @@ ngx-skeleton-loader {
|
||||
}
|
||||
}
|
||||
|
||||
.mat-paginator {
|
||||
background-color: rgba(var(--palette-foreground-base-light), 0.02);
|
||||
|
||||
.mat-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.no-min-width {
|
||||
min-width: unset !important;
|
||||
}
|
||||
@ -239,4 +263,13 @@ ngx-skeleton-loader {
|
||||
.mat-select-placeholder {
|
||||
color: rgba(var(--dark-primary-text));
|
||||
}
|
||||
|
||||
&.mat-select-disabled {
|
||||
.mat-select-placeholder {
|
||||
color: rgba(
|
||||
var(--palette-foreground-disabled-text),
|
||||
var(--palette-foreground-disabled-text-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
29
apps/ui-e2e/project.json
Normal file
29
apps/ui-e2e/project.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "ui-e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
23
libs/common/project.json
Normal file
23
libs/common/project.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "common",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -40,6 +40,7 @@ export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
|
||||
|
||||
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
|
||||
export const DEFAULT_LANGUAGE_CODE = 'en';
|
||||
export const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
|
||||
export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {
|
||||
|
58
libs/ui/project.json
Normal file
58
libs/ui/project.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"generators": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -18,6 +18,7 @@ import {
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatChipInputEvent } from '@angular/material/chips';
|
||||
import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { groupBy } from 'lodash';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -136,7 +137,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
|
||||
for (const type of Object.keys(filterGroupsMap)) {
|
||||
filterGroups.push({
|
||||
name: <Filter['type']>type,
|
||||
name: <Filter['type']>translate(type),
|
||||
filters: filterGroupsMap[type]
|
||||
});
|
||||
}
|
||||
|
@ -9,10 +9,10 @@
|
||||
<div class="activities">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="date"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="count">
|
||||
@ -27,7 +27,11 @@
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-cell
|
||||
>
|
||||
{{ dataSource.data.length - i }}
|
||||
{{
|
||||
dataSource.data.length > pageSize
|
||||
? dataSource.data.length - pageSize * pageIndex - i
|
||||
: dataSource.data.length - i
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
@ -51,7 +55,7 @@
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Type</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" mat-cell class="px-1">
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div
|
||||
class="d-inline-flex p-1 type-badge"
|
||||
[ngClass]="{
|
||||
@ -389,6 +393,10 @@
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
|
||||
}"
|
||||
(click)="
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
@ -398,10 +406,6 @@
|
||||
symbol: row.SymbolProfile.symbol
|
||||
})
|
||||
"
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
|
||||
}"
|
||||
></tr>
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
@ -411,6 +415,18 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
showFirstLastButtons="true"
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
isLoading ||
|
||||
dataSource.data.length === 0 ||
|
||||
dataSource.data.length <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
|
@ -8,10 +8,12 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
@ -37,6 +39,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Input() hasPermissionToImportActivities: boolean;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@Input() locale: string;
|
||||
@Input() pageSize = DEFAULT_PAGE_SIZE;
|
||||
@Input() showActions: boolean;
|
||||
@Input() showSymbolColumn = true;
|
||||
|
||||
@ -47,6 +50,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Output() exportDrafts = new EventEmitter<string[]>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public allFilters: Filter[];
|
||||
@ -59,6 +63,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
public isAfter = isAfter;
|
||||
public isLoading = true;
|
||||
public isUUID = isUUID;
|
||||
public pageIndex = 0;
|
||||
public placeholder = '';
|
||||
public routeQueryParams: Subscription;
|
||||
public searchKeywords: string[] = [];
|
||||
@ -119,12 +124,20 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
return contains;
|
||||
};
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.pageIndex = page.pageIndex;
|
||||
|
||||
this.totalFees = this.getTotalFees();
|
||||
this.totalValue = this.getTotalValue();
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: OrderWithAccount) {
|
||||
this.activityToClone.emit(aActivity);
|
||||
}
|
||||
@ -231,6 +244,21 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
return Object.values(fieldValueMap);
|
||||
}
|
||||
|
||||
private getPaginatedData() {
|
||||
if (this.dataSource.data.length > this.pageSize) {
|
||||
const sortedData = this.dataSource.sortData(
|
||||
this.dataSource.filteredData,
|
||||
this.dataSource.sort
|
||||
);
|
||||
|
||||
return sortedData.slice(
|
||||
this.pageIndex * this.pageSize,
|
||||
(this.pageIndex + 1) * this.pageSize
|
||||
);
|
||||
}
|
||||
return this.dataSource.filteredData;
|
||||
}
|
||||
|
||||
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
|
||||
const fieldValueMap: { [id: string]: Filter } = {};
|
||||
|
||||
@ -243,8 +271,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
|
||||
private getTotalFees() {
|
||||
let totalFees = new Big(0);
|
||||
|
||||
for (const activity of this.dataSource.filteredData) {
|
||||
const paginatedData = this.getPaginatedData();
|
||||
for (const activity of paginatedData) {
|
||||
if (isNumber(activity.feeInBaseCurrency)) {
|
||||
totalFees = totalFees.plus(activity.feeInBaseCurrency);
|
||||
} else {
|
||||
@ -257,8 +285,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
|
||||
private getTotalValue() {
|
||||
let totalValue = new Big(0);
|
||||
|
||||
for (const activity of this.dataSource.filteredData) {
|
||||
const paginatedData = this.getPaginatedData();
|
||||
for (const activity of paginatedData) {
|
||||
if (isNumber(activity.valueInBaseCurrency)) {
|
||||
if (activity.type === 'BUY' || activity.type === 'ITEM') {
|
||||
totalValue = totalValue.plus(activity.valueInBaseCurrency);
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
@ -26,6 +27,7 @@ import { ActivitiesTableComponent } from './activities-table.component';
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
|
38
libs/ui/src/lib/i18n.ts
Normal file
38
libs/ui/src/lib/i18n.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import '@angular/localize/init';
|
||||
|
||||
const locales = {
|
||||
ACCOUNT: $localize`Account`,
|
||||
ASSET_CLASS: $localize`Asset Class`,
|
||||
EMERGENCY_FUND: $localize`Emergency Fund`,
|
||||
OTHER: $localize`Other`,
|
||||
SYMBOL: $localize`Symbol`,
|
||||
TAG: $localize`Tag`,
|
||||
|
||||
// enum AssetClass
|
||||
CASH: $localize`Cash`,
|
||||
COMMODITY: $localize`Commodity`,
|
||||
EQUITY: $localize`Equity`,
|
||||
FIXED_INCOME: $localize`Fixed Income`,
|
||||
REAL_ESTATE: $localize`Real Estate`,
|
||||
|
||||
// enum AssetSubClass
|
||||
BOND: $localize`Bond`,
|
||||
CRYPTOCURRENCY: $localize`Cryptocurrency`,
|
||||
ETF: $localize`ETF`,
|
||||
MUTUALFUND: $localize`Mutual Fund`,
|
||||
PRECIOUS_METAL: $localize`Precious Metal`,
|
||||
PRIVATE_EQUITY: $localize`Private Equity`,
|
||||
STOCK: $localize`Stock`,
|
||||
|
||||
// Continents
|
||||
Africa: $localize`Africa`,
|
||||
Asia: $localize`Asia`,
|
||||
Europe: $localize`Europe`,
|
||||
'North America': $localize`North America`,
|
||||
Oceania: $localize`Oceania`,
|
||||
'South America': $localize`South America`
|
||||
};
|
||||
|
||||
export function translate(aKey: string) {
|
||||
return locales[aKey] ?? aKey;
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import '@angular/localize/init';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Meta, Story, moduleMetadata } from '@storybook/angular';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
@ -15,6 +15,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getTextColor } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { ChartConfiguration, Tooltip } from 'chart.js';
|
||||
@ -365,12 +366,12 @@ export class PortfolioProportionChartComponent
|
||||
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
|
||||
|
||||
if (symbol === this.OTHER_KEY) {
|
||||
symbol = 'Other';
|
||||
symbol = $localize`Other`;
|
||||
} else if (symbol === UNKNOWN_KEY) {
|
||||
symbol = 'No data available';
|
||||
symbol = $localize`No data available`;
|
||||
}
|
||||
|
||||
const name = this.positions[<string>symbol]?.name;
|
||||
const name = translate(this.positions[<string>symbol]?.name);
|
||||
|
||||
let sum = 0;
|
||||
for (const item of context.dataset.data) {
|
||||
@ -380,7 +381,7 @@ export class PortfolioProportionChartComponent
|
||||
const percentage = (context.parsed * 100) / sum;
|
||||
|
||||
if (<number>context.raw === Number.MAX_SAFE_INTEGER) {
|
||||
return 'No data available';
|
||||
return $localize`No data available`;
|
||||
} else if (this.isInPercent) {
|
||||
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
|
||||
} else {
|
||||
|
@ -44,7 +44,7 @@
|
||||
class="mb-0 text-truncate value"
|
||||
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
|
||||
>
|
||||
{{ formattedValue | titlecase }}
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user