Compare commits
110 Commits
Author | SHA1 | Date | |
---|---|---|---|
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 | |||
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 | |||
60b2115e3b | |||
e7956943ba | |||
f66edf8de0 | |||
29028a81f5 | |||
c9878c9050 | |||
73ac4b4197 | |||
016634a77f | |||
ea65dc5034 | |||
84db54babd | |||
653c9c62a8 | |||
74278073b3 | |||
0375b938a2 | |||
32df7620d9 | |||
8492a8fed0 | |||
30e561c06f | |||
7243090c0e | |||
7ae49eb839 | |||
bf816c3b89 | |||
20f9225daa | |||
b6101c6375 | |||
e1022846b9 | |||
9ba79f6721 | |||
0ac97bd112 | |||
827270704a | |||
8634463597 | |||
3905782ad6 | |||
5db984ffef | |||
fb3cd4b689 | |||
3b5a34f6f3 | |||
22b43b5bfc | |||
6c66033eb4 | |||
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c |
1
.github/workflows/docker-image.yml
vendored
1
.github/workflows/docker-image.yml
vendored
@ -21,6 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghostfolio/ghostfolio
|
images: ghostfolio/ghostfolio
|
||||||
tags: |
|
tags: |
|
||||||
|
type=semver,pattern={{major}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
|||||||
/.angular/cache
|
/.angular/cache
|
||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
|
.nx/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
|
/.nx/cache
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
188
CHANGELOG.md
188
CHANGELOG.md
@ -5,12 +5,186 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.18.0 - 2023-11-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to import activities by `isin` in the _Yahoo Finance_ service
|
||||||
|
- Added a new tag with the major version to the docker image on _Docker Hub_
|
||||||
|
- Added a blog post: _Hacktoberfest 2023 Debriefing_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `16.2.1` to `16.2.12`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue to get quotes in the _CoinGecko_ service
|
||||||
|
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
|
||||||
|
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
|
||||||
|
|
||||||
|
## 2.17.0 - 2023-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a button to edit the exchange rates in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the biometric authentication
|
||||||
|
- Fixed the alignment of the icons in various menus
|
||||||
|
|
||||||
|
## 2.16.0 - 2023-10-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||||
|
- Improved the usability and validation in the cash balance transfer from one to another account
|
||||||
|
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
||||||
|
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
||||||
|
- Improved the date parsing in the import historical market data of the admin control panel
|
||||||
|
- Improved the localized meta data (keywords) in `html` files
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.4.2` to `5.5.2`
|
||||||
|
|
||||||
|
## 2.15.0 - 2023-10-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style and wording of the position detail dialog
|
||||||
|
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
|
||||||
|
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
|
||||||
|
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
|
||||||
|
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
|
||||||
|
- Upgraded `uuid` from version `9.0.0` to `9.0.1`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the chart in the account detail dialog for accounts excluded from analysis
|
||||||
|
- Verified the current benchmark before loading it on the analysis page
|
||||||
|
|
||||||
|
## 2.14.0 - 2023-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
|
||||||
|
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the fees on account level feature from experimental to general availability
|
||||||
|
- Moved the interest on account level feature from experimental to general availability
|
||||||
|
- Moved the search for a holding from experimental to general availability
|
||||||
|
- Improved the error message in the activities import for `csv` files
|
||||||
|
- Removed the application version from the client
|
||||||
|
- Allowed to edit today’s historical market data in the asset profile details dialog of the admin control panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the style of the active page in the header navigation
|
||||||
|
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
|
||||||
|
|
||||||
|
## 2.13.0 - 2023-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a chart to the account detail dialog
|
||||||
|
- Added an `i18n` service to query `messages.*.xlf` files on the server
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the users table in the admin control panel to an `@angular/material` data table
|
||||||
|
- Improved the styling of the membership status
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue where holdings were requested twice from the server
|
||||||
|
|
||||||
|
## 2.12.0 - 2023-10-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
|
||||||
|
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
|
||||||
|
- Added support for creating asset profiles with `MANUAL` data source
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the checkboxes to slide toggles in the user settings of the user account page
|
||||||
|
- Extended the `copy-assets` `Nx` target to copy the locales to the server’s assets
|
||||||
|
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Displayed the transfer cash balance button based on a permission
|
||||||
|
- Fixed the biometric authentication
|
||||||
|
- Fixed the query to get asset profiles that match both the `dataSource` and `symbol` values
|
||||||
|
|
||||||
|
## 2.11.0 - 2023-10-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to transfer a part of the cash balance from one to another account
|
||||||
|
- Extended the markets overview by benchmarks (date of last all time high)
|
||||||
|
- Added support to import historical market data in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
|
||||||
|
- Fixed the displayed currency of the cash balance in the create or update account dialog
|
||||||
|
|
||||||
|
## 2.10.0 - 2023-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported enter key press to submit the form of the create or update access dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the display of the results in the search for a holding
|
||||||
|
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
|
||||||
|
- Improved the symbol conversion in the _EOD Historical Data_ service
|
||||||
|
|
||||||
|
## 2.9.0 - 2023-10-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
|
||||||
|
- Added support for notes in the activities import
|
||||||
|
- Added support to search in the platform selector of the create or update account dialog
|
||||||
|
- Added support for a search query in the portfolio position endpoint
|
||||||
|
- Added the application version to the endpoint `GET api/v1/admin`
|
||||||
|
- Introduced a carousel component for the testimonial section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Displayed the link to the markets overview on the home page without any permission
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the style of the active features page in the navigation on desktop
|
||||||
|
|
||||||
## 2.8.0 - 2023-10-03
|
## 2.8.0 - 2023-10-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Supported enter key press to submit the form of the create or update account dialog
|
- Supported enter key press to submit the form of the create or update account dialog
|
||||||
- Added the version to the admin control panel
|
- Added the application version to the admin control panel
|
||||||
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
|
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -76,13 +250,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
|
- Improved the preselected currency based on the account’s currency in the create or edit activity dialog
|
||||||
- Unlocked the experimental features setting for all users
|
- Unlocked the experimental features setting for all users
|
||||||
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
|
- Fixed a memory leak related to the server’s timezone (behind UTC) in the data gathering
|
||||||
|
|
||||||
## 2.3.0 - 2023-09-17
|
## 2.3.0 - 2023-09-17
|
||||||
|
|
||||||
@ -233,7 +407,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Optimized the activities import by allowing a different currency than the asset's official one
|
- Optimized the activities import by allowing a different currency than the asset’s official one
|
||||||
- Added a timeout to the _EOD Historical Data_ requests
|
- Added a timeout to the _EOD Historical Data_ requests
|
||||||
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
||||||
|
|
||||||
@ -740,7 +914,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Persisted today's market data continuously
|
- Persisted today’s market data continuously
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -974,7 +1148,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Filtered activities with type `ITEM` from search results
|
- Filtered activities with type `ITEM` from search results
|
||||||
- Considered the user's language in the _Stripe_ checkout
|
- Considered the user’s language in the _Stripe_ checkout
|
||||||
- Upgraded the _Stripe_ dependencies
|
- Upgraded the _Stripe_ dependencies
|
||||||
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||||
|
|
||||||
@ -2648,7 +2822,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Moved the countries and sectors charts in the position detail dialog
|
- Moved the countries and sectors charts in the position detail dialog
|
||||||
- Distinguished today's data point of historical data in the admin control panel
|
- Distinguished today’s data point of historical data in the admin control panel
|
||||||
- Restructured the server modules
|
- Restructured the server modules
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -1,5 +1,17 @@
|
|||||||
# Ghostfolio Development Guide
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Experimental Features
|
||||||
|
|
||||||
|
New functionality can be enabled using a feature flag switch from the user settings.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Remove permission in `UserService` using `without()`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
### Rebase
|
### Rebase
|
||||||
@ -8,6 +20,12 @@
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
### Angular
|
||||||
|
|
||||||
|
#### Upgrade (minor versions)
|
||||||
|
|
||||||
|
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||||
|
|
||||||
### Nx
|
### Nx
|
||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
@ -18,6 +36,12 @@
|
|||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `yarn database:push`
|
||||||
|
@ -8,4 +8,8 @@ export class CreateAccessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AccountBalancesResponse,
|
||||||
|
Accounts
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CreateAccountDto } from './create-account.dto';
|
import { CreateAccountDto } from './create-account.dto';
|
||||||
|
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from './update-account.dto';
|
import { UpdateAccountDto } from './update-account.dto';
|
||||||
|
|
||||||
@Controller('account')
|
@Controller('account')
|
||||||
export class AccountController {
|
export class AccountController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -115,6 +121,18 @@ export class AccountController {
|
|||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/balances')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
public async getAccountBalancesById(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalancesResponse> {
|
||||||
|
return this.accountBalanceService.getAccountBalances({
|
||||||
|
accountId: id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
@ -154,6 +172,68 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('transfer-balance')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async transferAccountBalance(
|
||||||
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountFrom = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdFrom;
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountTo = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountFrom || !accountTo) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountFrom.id === accountTo.id) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountFrom.balance < balance) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountFrom.id,
|
||||||
|
amount: -balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountTo.id,
|
||||||
|
amount: balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
|
@ -109,7 +109,7 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string) {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -218,13 +218,13 @@ export class AccountService {
|
|||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
date,
|
date = new Date(),
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
date: Date;
|
date?: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const { balance, currency: currencyOfAccount } = await this.account({
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TransferBalanceDto {
|
||||||
|
@IsString()
|
||||||
|
accountIdFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
accountIdTo: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -1,19 +1,21 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
@ -43,12 +45,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
@ -254,6 +258,7 @@ export class AdminController {
|
|||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@ -271,16 +276,10 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAssetSubClasses,
|
||||||
const filters: Filter[] = [
|
filterBySearchQuery
|
||||||
...assetSubClasses.map((assetSubClass) => {
|
});
|
||||||
return <Filter>{
|
|
||||||
id: assetSubClass,
|
|
||||||
type: 'ASSET_SUB_CLASS'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
return this.adminService.getMarketData({
|
return this.adminService.getMarketData({
|
||||||
filters,
|
filters,
|
||||||
@ -313,6 +312,43 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('market-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateMarketData(
|
||||||
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
|
({ date, marketPrice }) => ({
|
||||||
|
dataSource,
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
date: resetHours(parseISO(date)),
|
||||||
|
state: 'CLOSE'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMany({
|
||||||
|
data: dataBulkUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(
|
public async update(
|
||||||
@ -365,8 +401,11 @@ export class AdminController {
|
|||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return this.adminService.addAssetProfile({
|
||||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
currency: this.request.user.Settings.settings.baseCurrency
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -22,7 +23,13 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Prisma,
|
||||||
|
Property,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@ -40,10 +47,19 @@ export class AdminService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async addAssetProfile({
|
public async addAssetProfile({
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||||
try {
|
try {
|
||||||
|
if (dataSource === 'MANUAL') {
|
||||||
|
return this.symbolProfileService.add({
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
]);
|
||||||
@ -84,9 +100,17 @@ export class AdminService {
|
|||||||
return currency !== DEFAULT_CURRENCY;
|
return currency !== DEFAULT_CURRENCY;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
|
const label1 = DEFAULT_CURRENCY;
|
||||||
|
const label2 = currency;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label1: DEFAULT_CURRENCY,
|
label1,
|
||||||
label2: currency,
|
label2,
|
||||||
|
dataSource:
|
||||||
|
DataSource[
|
||||||
|
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||||
|
],
|
||||||
|
symbol: `${label1}${label2}`,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
@ -97,7 +121,8 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,10 +154,14 @@ export class AdminService {
|
|||||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -145,6 +174,14 @@ export class AdminService {
|
|||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
where.OR = [
|
||||||
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
@ -171,7 +208,9 @@ export class AdminService {
|
|||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
@ -192,7 +231,9 @@ export class AdminService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
@ -211,8 +252,10 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
@ -274,15 +317,21 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
@ -339,6 +388,8 @@ export class AdminService {
|
|||||||
symbol,
|
symbol,
|
||||||
assetClass: 'CASH',
|
assetClass: 'CASH',
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,23 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
scraperConfiguration?: Prisma.InputJsonObject;
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||||
|
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
export class UpdateBulkMarketDataDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => UpdateMarketDataDto)
|
||||||
|
marketData: UpdateMarketDataDto[];
|
||||||
|
}
|
@ -1,6 +1,10 @@
|
|||||||
import { IsNumber } from 'class-validator';
|
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateMarketDataDto {
|
export class UpdateMarketDataDto {
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateRegistrationOptions(opts);
|
const options = await generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -88,10 +88,16 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedRegistrationResponse;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyRegistrationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,8 +123,8 @@ export class WebAuthService {
|
|||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
counter,
|
counter,
|
||||||
credentialPublicKey,
|
credentialId: Buffer.from(credentialID),
|
||||||
credentialId: credentialID,
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -152,7 +158,7 @@ export class WebAuthService {
|
|||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAuthenticationOptions(opts);
|
const options = await generateAuthenticationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -181,7 +187,6 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedAuthenticationResponse;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAuthenticationResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
@ -189,9 +194,16 @@ export class WebAuthService {
|
|||||||
},
|
},
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = await verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'WebAuthService');
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
|
@ -64,7 +64,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
@ -85,15 +85,14 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
if (allTimeHigh && marketPrice) {
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
allTimeHigh,
|
allTimeHigh.marketPrice,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
storeInCache = false;
|
storeInCache = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
@ -101,6 +100,7 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -83,6 +83,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const isDuplicate = orders.some((activity) => {
|
const isDuplicate = orders.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === Account?.id &&
|
||||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
isSameDay(activity.date, parseDate(dateString)) &&
|
isSameDay(activity.date, parseDate(dateString)) &&
|
||||||
@ -280,6 +281,9 @@ export class ImportService {
|
|||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -350,6 +354,9 @@ export class ImportService {
|
|||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -476,6 +483,7 @@ export class ImportService {
|
|||||||
const date = parseISO(<string>(<unknown>dateString));
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
const isDuplicate = existingActivities.some((activity) => {
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === accountId &&
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(activity.date, date) &&
|
isSameDay(activity.date, date) &&
|
||||||
@ -509,6 +517,9 @@ export class ImportService {
|
|||||||
comment: null,
|
comment: null,
|
||||||
countries: null,
|
countries: null,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
|
figi: null,
|
||||||
|
figiComposite: null,
|
||||||
|
figiShareClass: null,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
isin: null,
|
||||||
name: null,
|
name: null,
|
||||||
|
@ -55,12 +55,8 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = (
|
const platforms = await this.platformService.getPlatforms({
|
||||||
await this.platformService.getPlatforms({
|
orderBy: { name: 'asc' }
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
})
|
|
||||||
).map(({ id, name }) => {
|
|
||||||
return { id, name };
|
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
|
@ -13,7 +13,8 @@ import {
|
|||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -48,9 +49,11 @@ export class CreateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -64,6 +67,7 @@ export class CreateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@ -8,12 +8,12 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -47,12 +47,14 @@ export class UpdateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -66,5 +68,6 @@ export class UpdateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -323,7 +323,8 @@ export class PortfolioController {
|
|||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -335,6 +336,7 @@ export class PortfolioController {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
|
withExcludedAccounts,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -391,12 +393,14 @@ export class PortfolioController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -372,20 +372,23 @@ export class PortfolioService {
|
|||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<HistoricalDataContainer> {
|
}): Promise<HistoricalDataContainer> {
|
||||||
userId = await this.getUserId(impersonationId, userId);
|
userId = await this.getUserId(impersonationId, userId);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
filters,
|
filters,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
@ -1014,6 +1017,9 @@ export class PortfolioService {
|
|||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -1042,9 +1048,9 @@ export class PortfolioService {
|
|||||||
const currentPositions =
|
const currentPositions =
|
||||||
await portfolioCalculator.getCurrentPositions(startDate);
|
await portfolioCalculator.getCurrentPositions(startDate);
|
||||||
|
|
||||||
const positions = currentPositions.positions.filter(
|
let positions = currentPositions.positions.filter(({ quantity }) => {
|
||||||
(item) => !item.quantity.eq(0)
|
return !quantity.eq(0);
|
||||||
);
|
});
|
||||||
|
|
||||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
@ -1067,12 +1073,25 @@ export class PortfolioService {
|
|||||||
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
positions = positions.filter(({ symbol }) => {
|
||||||
|
const enhancedSymbolProfile = symbolProfileMap[symbol];
|
||||||
|
|
||||||
|
return (
|
||||||
|
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasErrors: currentPositions.hasErrors,
|
hasErrors: currentPositions.hasErrors,
|
||||||
positions: positions.map((position) => {
|
positions: positions.map((position) => {
|
||||||
return {
|
return {
|
||||||
...position,
|
...position,
|
||||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||||
|
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
||||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
@ -1094,12 +1113,14 @@ export class PortfolioService {
|
|||||||
dateRange = 'max',
|
dateRange = 'max',
|
||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<PortfolioPerformanceResponse> {
|
}): Promise<PortfolioPerformanceResponse> {
|
||||||
userId = await this.getUserId(impersonationId, userId);
|
userId = await this.getUserId(impersonationId, userId);
|
||||||
const user = await this.userService.user({ id: userId });
|
const user = await this.userService.user({ id: userId });
|
||||||
@ -1108,7 +1129,8 @@ export class PortfolioService {
|
|||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
filters,
|
filters,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
@ -1158,7 +1180,8 @@ export class PortfolioService {
|
|||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||||
@ -1747,7 +1770,7 @@ export class PortfolioService {
|
|||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
@ -1835,7 +1858,7 @@ export class PortfolioService {
|
|||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
@ -1869,9 +1892,13 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const accountIds = uniq(
|
const accountIds = uniq(
|
||||||
orders.map(({ accountId }) => {
|
orders
|
||||||
return accountId;
|
.filter(({ accountId }) => {
|
||||||
})
|
return accountId;
|
||||||
|
})
|
||||||
|
.map(({ accountId }) => {
|
||||||
|
return accountId;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
@ -104,7 +104,7 @@ export class SubscriptionController {
|
|||||||
response.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,6 +163,13 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||||
|
// currentPermissions = without(
|
||||||
|
// currentPermissions,
|
||||||
|
// permissions.xyz
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
@ -54,10 +54,22 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -82,6 +94,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -94,6 +110,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -134,6 +154,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -166,6 +190,14 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -274,6 +306,10 @@
|
|||||||
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/faq</loc>
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -308,10 +344,22 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -336,6 +384,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -348,6 +400,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -388,6 +444,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -420,6 +480,14 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -590,12 +658,24 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
@ -618,6 +698,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -630,6 +714,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -670,6 +758,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -702,6 +794,14 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -718,10 +818,22 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -746,6 +858,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -758,6 +874,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -798,6 +918,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -830,6 +954,14 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
|
|||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
for (const key in aObject) {
|
for (const key in aObject) {
|
||||||
if (aObject[key] === null || aObject[key] === null) {
|
if (aObject[key] === null || aObject[key] === undefined) {
|
||||||
return true;
|
return true;
|
||||||
} else if (isObject(aObject[key])) {
|
} else if (isObject(aObject[key])) {
|
||||||
return hasNotDefinedValuesInObject(aObject[key]);
|
return hasNotDefinedValuesInObject(aObject[key]);
|
||||||
|
@ -2,6 +2,7 @@ import * as fs from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_ROOT_URL,
|
DEFAULT_ROOT_URL,
|
||||||
@ -11,22 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
const descriptions = {
|
const i18nService = new I18nService();
|
||||||
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
|
||||||
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
|
|
||||||
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
|
||||||
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
|
||||||
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
|
||||||
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
|
||||||
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
|
||||||
tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = 'Ghostfolio – Open Source Wealth Management Software';
|
|
||||||
const titleShort = 'Ghostfolio';
|
|
||||||
|
|
||||||
let indexHtmlMap: { [languageCode: string]: string } = {};
|
let indexHtmlMap: { [languageCode: string]: string } = {};
|
||||||
|
|
||||||
|
const title = 'Ghostfolio';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
|
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
|
||||||
(map, languageCode) => ({
|
(map, languageCode) => ({
|
||||||
@ -43,47 +34,51 @@ try {
|
|||||||
const locales = {
|
const locales = {
|
||||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
|
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
|
||||||
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
|
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2022/08/500-stars-on-github': {
|
'/en/blog/2022/08/500-stars-on-github': {
|
||||||
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
|
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
|
||||||
title: `500 Stars - ${titleShort}`
|
title: `500 Stars - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2022/10/hacktoberfest-2022': {
|
'/en/blog/2022/10/hacktoberfest-2022': {
|
||||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
|
||||||
title: `Hacktoberfest 2022 - ${titleShort}`
|
title: `Hacktoberfest 2022 - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
|
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
|
||||||
featureGraphicPath: 'assets/images/blog/20221226.jpg',
|
featureGraphicPath: 'assets/images/blog/20221226.jpg',
|
||||||
title: `The importance of tracking your personal finances - ${titleShort}`
|
title: `The importance of tracking your personal finances - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
|
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
|
||||||
title: `Ghostfolio meets Umbrel - ${titleShort}`
|
title: `Ghostfolio meets Umbrel - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
|
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
|
||||||
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
|
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
|
||||||
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}`
|
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
|
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
|
||||||
featureGraphicPath: 'assets/images/blog/20230520.jpg',
|
featureGraphicPath: 'assets/images/blog/20230520.jpg',
|
||||||
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
|
title: `Unlock your Financial Potential with Ghostfolio - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/07/exploring-the-path-to-fire': {
|
'/en/blog/2023/07/exploring-the-path-to-fire': {
|
||||||
featureGraphicPath: 'assets/images/blog/20230701.jpg',
|
featureGraphicPath: 'assets/images/blog/20230701.jpg',
|
||||||
title: `Exploring the Path to FIRE - ${titleShort}`
|
title: `Exploring the Path to FIRE - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
|
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
|
||||||
title: `Ghostfolio joins OSS Friends - ${titleShort}`
|
title: `Ghostfolio joins OSS Friends - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/09/ghostfolio-2': {
|
'/en/blog/2023/09/ghostfolio-2': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
||||||
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
title: `Announcing Ghostfolio 2.0 - ${title}`
|
||||||
},
|
},
|
||||||
'/en/blog/2023/09/hacktoberfest-2023': {
|
'/en/blog/2023/09/hacktoberfest-2023': {
|
||||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
title: `Hacktoberfest 2023 - ${titleShort}`
|
title: `Hacktoberfest 2023 - ${title}`
|
||||||
|
},
|
||||||
|
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
|
||||||
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
|
title: `Hacktoberfest 2023 Debriefing - ${title}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,10 +125,22 @@ export const HtmlTemplateMiddleware = async (
|
|||||||
languageCode,
|
languageCode,
|
||||||
path,
|
path,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
description: descriptions[languageCode],
|
description: i18nService.getTranslation({
|
||||||
|
languageCode,
|
||||||
|
id: 'metaDescription'
|
||||||
|
}),
|
||||||
featureGraphicPath:
|
featureGraphicPath:
|
||||||
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
|
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
|
||||||
title: locales[path]?.title ?? title
|
keywords: i18nService.getTranslation({
|
||||||
|
languageCode,
|
||||||
|
id: 'metaKeywords'
|
||||||
|
}),
|
||||||
|
title:
|
||||||
|
locales[path]?.title ??
|
||||||
|
`${title} – ${i18nService.getTranslation({
|
||||||
|
languageCode,
|
||||||
|
id: 'slogan'
|
||||||
|
})}`
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.send(indexHtml);
|
return response.send(indexHtml);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@ -13,4 +14,29 @@ export class AccountBalanceService {
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAccountBalances({
|
||||||
|
accountId,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<AccountBalancesResponse> {
|
||||||
|
const balances = await this.prismaService.accountBalance.findMany({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
id: true,
|
||||||
|
value: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
accountId,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { balances };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,14 +8,20 @@ export class ApiService {
|
|||||||
public buildFiltersFromQueryParams({
|
public buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterByAssetSubClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
}: {
|
}: {
|
||||||
filterByAccounts?: string;
|
filterByAccounts?: string;
|
||||||
filterByAssetClasses?: string;
|
filterByAssetClasses?: string;
|
||||||
|
filterByAssetSubClasses?: string;
|
||||||
|
filterBySearchQuery?: string;
|
||||||
filterByTags?: string;
|
filterByTags?: string;
|
||||||
}): Filter[] {
|
}): Filter[] {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -31,6 +37,16 @@ export class ApiService {
|
|||||||
type: 'ASSET_CLASS'
|
type: 'ASSET_CLASS'
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
...assetSubClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
id: searchQuery,
|
||||||
|
type: 'SEARCH_QUERY'
|
||||||
|
},
|
||||||
...tagIds.map((tagId) => {
|
...tagIds.map((tagId) => {
|
||||||
return <Filter>{
|
return <Filter>{
|
||||||
id: tagId,
|
id: tagId,
|
||||||
|
@ -38,6 +38,7 @@ export class ConfigurationService {
|
|||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
|
OPEN_FIGI_API_KEY: str({ default: '' }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAPID_API_API_KEY: str({ default: '' }),
|
RAPID_API_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
|
@ -164,6 +164,9 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
@ -178,6 +181,9 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
@ -189,6 +195,9 @@ export class DataGatheringService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
|
@ -105,9 +105,11 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return DataSource.ALPHA_VANTAGE;
|
return DataSource.ALPHA_VANTAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,13 +134,15 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
return DataSource.COINGECKO;
|
return DataSource.COINGECKO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
if (aSymbols.length <= 0) {
|
if (symbols.length <= 0) {
|
||||||
return {};
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -150,8 +152,8 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const response = await got(
|
const quotes = await got(
|
||||||
`${this.URL}/simple/price?ids=${aSymbols.join(
|
`${this.URL}/simple/price?ids=${symbols.join(
|
||||||
','
|
','
|
||||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||||
{
|
{
|
||||||
@ -160,22 +162,20 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
for (const symbol in response) {
|
for (const symbol in quotes) {
|
||||||
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
response[symbol] = {
|
||||||
results[symbol] = {
|
currency: DEFAULT_CURRENCY,
|
||||||
currency: DEFAULT_CURRENCY,
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
dataProviderInfo: this.getDataProviderInfo(),
|
dataSource: DataSource.COINGECKO,
|
||||||
dataSource: DataSource.COINGECKO,
|
marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
|
||||||
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
|
marketState: 'open'
|
||||||
marketState: 'open'
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'CoinGeckoService');
|
Logger.error(error, 'CoinGeckoService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTestSymbol() {
|
public getTestSymbol() {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
|
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
|
||||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
|
|||||||
@Module({
|
@Module({
|
||||||
exports: [
|
exports: [
|
||||||
DataEnhancerService,
|
DataEnhancerService,
|
||||||
|
OpenFigiDataEnhancerService,
|
||||||
TrackinsightDataEnhancerService,
|
TrackinsightDataEnhancerService,
|
||||||
YahooFinanceDataEnhancerService,
|
YahooFinanceDataEnhancerService,
|
||||||
'DataEnhancers'
|
'DataEnhancers'
|
||||||
@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
|
|||||||
imports: [ConfigurationModule, CryptocurrencyModule],
|
imports: [ConfigurationModule, CryptocurrencyModule],
|
||||||
providers: [
|
providers: [
|
||||||
DataEnhancerService,
|
DataEnhancerService,
|
||||||
|
OpenFigiDataEnhancerService,
|
||||||
TrackinsightDataEnhancerService,
|
TrackinsightDataEnhancerService,
|
||||||
YahooFinanceDataEnhancerService,
|
YahooFinanceDataEnhancerService,
|
||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
|
OpenFigiDataEnhancerService,
|
||||||
TrackinsightDataEnhancerService,
|
TrackinsightDataEnhancerService,
|
||||||
YahooFinanceDataEnhancerService
|
YahooFinanceDataEnhancerService
|
||||||
],
|
],
|
||||||
provide: 'DataEnhancers',
|
provide: 'DataEnhancers',
|
||||||
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
|
useFactory: (openfigi, trackinsight, yahooFinance) => [
|
||||||
|
openfigi,
|
||||||
|
trackinsight,
|
||||||
|
yahooFinance
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
|
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||||
|
import { parseSymbol } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import got, { Headers } from 'got';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||||
|
private static baseUrl = 'https://api.openfigi.com';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: Partial<SymbolProfile>;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
response.assetClass === 'EQUITY' &&
|
||||||
|
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Headers = {};
|
||||||
|
const { exchange, ticker } = parseSymbol({
|
||||||
|
symbol,
|
||||||
|
dataSource: response.dataSource
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
|
||||||
|
headers['X-OPENFIGI-APIKEY'] =
|
||||||
|
this.configurationService.get('OPEN_FIGI_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
let abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
const mappings = await got
|
||||||
|
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||||
|
headers,
|
||||||
|
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
})
|
||||||
|
.json<any[]>();
|
||||||
|
|
||||||
|
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
|
||||||
|
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
|
||||||
|
|
||||||
|
if (figi) {
|
||||||
|
response.figi = figi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compositeFIGI) {
|
||||||
|
response.figiComposite = compositeFIGI;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareClassFIGI) {
|
||||||
|
response.figiShareClass = shareClassFIGI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return 'OPENFIGI';
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTestSymbol() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { isISIN } from 'class-validator';
|
||||||
import { countries } from 'countries-list';
|
import { countries } from 'countries-list';
|
||||||
import yahooFinance from 'yahoo-finance2';
|
import yahooFinance from 'yahoo-finance2';
|
||||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||||
@ -156,7 +157,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
const response: Partial<SymbolProfile> = {};
|
const response: Partial<SymbolProfile> = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
let symbol = aSymbol;
|
||||||
|
|
||||||
|
if (isISIN(symbol)) {
|
||||||
|
try {
|
||||||
|
const { quotes } = await yahooFinance.search(symbol);
|
||||||
|
|
||||||
|
if (quotes.length === 1) {
|
||||||
|
symbol = quotes[0].symbol;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
symbol = this.convertToYahooFinanceSymbol(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||||
});
|
});
|
||||||
@ -176,7 +190,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
shortName: assetProfile.price.shortName,
|
shortName: assetProfile.price.shortName,
|
||||||
symbol: assetProfile.price.symbol
|
symbol: assetProfile.price.symbol
|
||||||
});
|
});
|
||||||
response.symbol = aSymbol;
|
response.symbol = assetProfile.price.symbol;
|
||||||
|
|
||||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
|
@ -311,7 +311,9 @@ export class DataProviderService {
|
|||||||
i + maximumNumberOfSymbolsPerRequest
|
i + maximumNumberOfSymbolsPerRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
|
const promise = Promise.resolve(
|
||||||
|
dataProvider.getQuotes({ symbols: symbolsChunk })
|
||||||
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then(async (result) => {
|
promise.then(async (result) => {
|
||||||
|
@ -131,17 +131,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return DataSource.EOD_HISTORICAL_DATA;
|
return DataSource.EOD_HISTORICAL_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
const symbols = aSymbols.map((symbol) => {
|
symbols: string[];
|
||||||
return this.convertToEodSymbol(symbol);
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
});
|
let response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
if (symbols.length <= 0) {
|
if (symbols.length <= 0) {
|
||||||
return {};
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eodHistoricalDataSymbols = symbols.map((symbol) => {
|
||||||
|
return this.convertToEodSymbol(symbol);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
@ -150,9 +154,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const realTimeResponse = await got(
|
const realTimeResponse = await got(
|
||||||
`${this.URL}/real-time/${symbols[0]}?api_token=${
|
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
|
||||||
this.apiKey
|
this.apiKey
|
||||||
}&fmt=json&s=${symbols.join(',')}`,
|
}&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
@ -160,10 +164,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
const quotes =
|
const quotes =
|
||||||
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
eodHistoricalDataSymbols.length === 1
|
||||||
|
? [realTimeResponse]
|
||||||
|
: realTimeResponse;
|
||||||
|
|
||||||
const searchResponse = await Promise.all(
|
const searchResponse = await Promise.all(
|
||||||
symbols
|
eodHistoricalDataSymbols
|
||||||
.filter((symbol) => {
|
.filter((symbol) => {
|
||||||
return !symbol.endsWith('.FOREX');
|
return !symbol.endsWith('.FOREX');
|
||||||
})
|
})
|
||||||
@ -176,7 +182,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return items[0];
|
return items[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = quotes.reduce(
|
response = quotes.reduce(
|
||||||
(
|
(
|
||||||
result: { [symbol: string]: IDataProviderResponse },
|
result: { [symbol: string]: IDataProviderResponse },
|
||||||
{ close, code, timestamp }
|
{ close, code, timestamp }
|
||||||
@ -283,7 +289,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
if (symbol.endsWith('.FOREX')) {
|
if (symbol.endsWith('.FOREX')) {
|
||||||
symbol = symbol.replace('GBX', 'GBp');
|
symbol = symbol.replace('GBX', 'GBp');
|
||||||
symbol = symbol.replace('.FOREX', '');
|
symbol = symbol.replace('.FOREX', '');
|
||||||
symbol = `${DEFAULT_CURRENCY}${symbol}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return symbol;
|
return symbol;
|
||||||
@ -292,7 +297,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
/**
|
/**
|
||||||
* Converts a symbol to a EOD symbol
|
* Converts a symbol to a EOD symbol
|
||||||
*
|
*
|
||||||
* Currency: USDCHF -> CHF.FOREX
|
* Currency: USDCHF -> USDCHF.FOREX
|
||||||
*/
|
*/
|
||||||
private convertToEodSymbol(aSymbol: string) {
|
private convertToEodSymbol(aSymbol: string) {
|
||||||
if (
|
if (
|
||||||
@ -304,9 +309,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return `${aSymbol
|
let symbol = aSymbol;
|
||||||
.replace('GBp', 'GBX')
|
symbol = symbol.replace('GBp', 'GBX');
|
||||||
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
|
|
||||||
|
return `${symbol}.FOREX`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,13 +113,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
return DataSource.FINANCIAL_MODELING_PREP;
|
return DataSource.FINANCIAL_MODELING_PREP;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
if (aSymbols.length <= 0) {
|
if (symbols.length <= 0) {
|
||||||
return {};
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -130,7 +132,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const response = await got(
|
const response = await got(
|
||||||
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
|
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
@ -138,7 +140,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
for (const { price, symbol } of response) {
|
for (const { price, symbol } of response) {
|
||||||
results[symbol] = {
|
response[symbol] = {
|
||||||
currency: DEFAULT_CURRENCY,
|
currency: DEFAULT_CURRENCY,
|
||||||
dataProviderInfo: this.getDataProviderInfo(),
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
dataSource: DataSource.FINANCIAL_MODELING_PREP,
|
dataSource: DataSource.FINANCIAL_MODELING_PREP,
|
||||||
@ -150,7 +152,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
Logger.error(error, 'FinancialModelingPrepService');
|
Logger.error(error, 'FinancialModelingPrepService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTestSymbol() {
|
public getTestSymbol() {
|
||||||
|
@ -99,18 +99,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
return DataSource.GOOGLE_SHEETS;
|
return DataSource.GOOGLE_SHEETS;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
if (aSymbols.length <= 0) {
|
symbols: string[];
|
||||||
return {};
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (symbols.length <= 0) {
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
|
||||||
|
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
aSymbols.map((symbol) => {
|
symbols.map((symbol) => {
|
||||||
return {
|
return {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: this.getName()
|
dataSource: this.getName()
|
||||||
@ -129,7 +131,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
const marketPrice = parseFloat(row['marketPrice']);
|
const marketPrice = parseFloat(row['marketPrice']);
|
||||||
const symbol = row['symbol'];
|
const symbol = row['symbol'];
|
||||||
|
|
||||||
if (aSymbols.includes(symbol)) {
|
if (symbols.includes(symbol)) {
|
||||||
response[symbol] = {
|
response[symbol] = {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: symbolProfiles.find((symbolProfile) => {
|
currency: symbolProfiles.find((symbolProfile) => {
|
||||||
|
@ -36,9 +36,11 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
getQuotes(
|
getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
}: {
|
||||||
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||||
|
|
||||||
getTestSymbol(): string;
|
getTestSymbol(): string;
|
||||||
|
|
||||||
|
@ -133,18 +133,20 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return DataSource.MANUAL;
|
return DataSource.MANUAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
if (aSymbols.length <= 0) {
|
if (symbols.length <= 0) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
aSymbols.map((symbol) => {
|
symbols.map((symbol) => {
|
||||||
return { symbol, dataSource: this.getName() };
|
return { symbol, dataSource: this.getName() };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -154,10 +156,10 @@ export class ManualService implements DataProviderInterface {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc'
|
date: 'desc'
|
||||||
},
|
},
|
||||||
take: aSymbols.length,
|
take: symbols.length,
|
||||||
where: {
|
where: {
|
||||||
symbol: {
|
symbol: {
|
||||||
in: aSymbols
|
in: symbols
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -87,15 +87,17 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
return DataSource.RAPID_API;
|
return DataSource.RAPID_API;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
if (aSymbols.length <= 0) {
|
symbols: string[];
|
||||||
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (symbols.length <= 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const symbol = symbols[0];
|
||||||
|
|
||||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
|
@ -30,7 +30,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
const { assetClass, assetSubClass, currency, name } =
|
const { assetClass, assetSubClass, currency, name, symbol } =
|
||||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -38,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency,
|
currency,
|
||||||
name,
|
name,
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,20 +156,22 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return DataSource.YAHOO;
|
return DataSource.YAHOO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes({
|
||||||
aSymbols: string[]
|
symbols
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}: {
|
||||||
if (aSymbols.length <= 0) {
|
symbols: string[];
|
||||||
return {};
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (symbols.length <= 0) {
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
const yahooFinanceSymbols = symbols.map((symbol) =>
|
||||||
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
|
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
|
||||||
|
|
||||||
let quotes: Pick<
|
let quotes: Pick<
|
||||||
Quote,
|
Quote,
|
||||||
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
|
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
|
||||||
|
67
apps/api/src/services/i18n/i18n.service.ts
Normal file
67
apps/api/src/services/i18n/i18n.service.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { readFileSync, readdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
export class I18nService {
|
||||||
|
private localesPath = join(__dirname, 'assets', 'locales');
|
||||||
|
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTranslation({
|
||||||
|
id,
|
||||||
|
languageCode
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
languageCode: string;
|
||||||
|
}): string {
|
||||||
|
const $ = this.translations[languageCode];
|
||||||
|
|
||||||
|
if (!$) {
|
||||||
|
Logger.warn(`Translation not found for locale '${languageCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translatedText = $(
|
||||||
|
`trans-unit[id="${id}"] > ${
|
||||||
|
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
|
||||||
|
}`
|
||||||
|
).text();
|
||||||
|
|
||||||
|
if (!translatedText) {
|
||||||
|
Logger.warn(
|
||||||
|
`Translation not found for id '${id}' in locale '${languageCode}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFiles() {
|
||||||
|
try {
|
||||||
|
const files = readdirSync(this.localesPath, 'utf-8');
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const xmlData = readFileSync(join(this.localesPath, file), 'utf8');
|
||||||
|
this.translations[this.parseLanguageCode(file)] =
|
||||||
|
this.parseXml(xmlData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'I18nService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLanguageCode(aFileName: string) {
|
||||||
|
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/);
|
||||||
|
|
||||||
|
return match ? match[1] : DEFAULT_LANGUAGE_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseXml(xmlData: string): cheerio.CheerioAPI {
|
||||||
|
return cheerio.load(xmlData, { xmlMode: true });
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
JWT_SECRET_KEY: string;
|
JWT_SECRET_KEY: string;
|
||||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
|
OPEN_FIGI_API_KEY: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
RAPID_API_API_KEY: string;
|
RAPID_API_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
|
@ -39,18 +39,22 @@ export class MarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
|
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
||||||
const aggregations = await this.prismaService.marketData.aggregate({
|
return this.prismaService.marketData.findFirst({
|
||||||
_max: {
|
select: {
|
||||||
|
date: true,
|
||||||
marketPrice: true
|
marketPrice: true
|
||||||
},
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
marketPrice: 'desc'
|
||||||
|
}
|
||||||
|
],
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return aggregations._max.marketPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
|
@ -52,20 +52,12 @@ export class SymbolProfileService {
|
|||||||
SymbolProfileOverrides: true
|
SymbolProfileOverrides: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
{
|
return {
|
||||||
dataSource: {
|
dataSource,
|
||||||
in: aUniqueAssets.map(({ dataSource }) => {
|
symbol
|
||||||
return dataSource;
|
};
|
||||||
})
|
})
|
||||||
},
|
|
||||||
symbol: {
|
|
||||||
in: aUniqueAssets.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
@ -94,14 +86,24 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public updateSymbolProfile({
|
public updateSymbolProfile({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.update({
|
return this.prismaService.symbolProfile.update({
|
||||||
data: { comment, scraperConfiguration, symbolMapping },
|
data: {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
symbolMapping
|
||||||
|
},
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -124,6 +124,9 @@
|
|||||||
{
|
{
|
||||||
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||||
},
|
},
|
||||||
|
@ -73,6 +73,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'i18n',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: paths.markets,
|
path: paths.markets,
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
|
[deviceType]="deviceType"
|
||||||
[hasTabs]="hasTabs"
|
[hasTabs]="hasTabs"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[pageTitle]="pageTitle"
|
[pageTitle]="pageTitle"
|
||||||
@ -164,7 +165,6 @@
|
|||||||
<div class="row text-center">
|
<div class="row text-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||||
{{ version }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { filter, takeUntil } from 'rxjs/operators';
|
import { filter, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { environment } from '../environments/environment';
|
|
||||||
import { DataService } from './services/data.service';
|
import { DataService } from './services/data.service';
|
||||||
import { TokenStorageService } from './services/token-storage.service';
|
import { TokenStorageService } from './services/token-storage.service';
|
||||||
import { UserService } from './services/user/user.service';
|
import { UserService } from './services/user/user.service';
|
||||||
@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public routerLinkResources = ['/' + $localize`resources`];
|
public routerLinkResources = ['/' + $localize`resources`];
|
||||||
public showFooter = false;
|
public showFooter = false;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
bootstrap: [AppComponent],
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
|
|||||||
useFactory: NgxStripeFactory
|
useFactory: NgxStripeFactory
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,15 +1,3 @@
|
|||||||
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
|
|
||||||
<a
|
|
||||||
color="primary"
|
|
||||||
i18n
|
|
||||||
mat-flat-button
|
|
||||||
[queryParams]="{ createDialog: true }"
|
|
||||||
[routerLink]="[]"
|
|
||||||
>
|
|
||||||
Add Access
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="alias">
|
<ng-container matColumnDef="alias">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
||||||
|
@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
})
|
})
|
||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
@Input() hasPermissionToCreateAccess = false;
|
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
|
|
||||||
@Output() accessDeleted = new EventEmitter<string>();
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
@ -3,5 +3,9 @@
|
|||||||
|
|
||||||
.mat-mdc-dialog-content {
|
.mat-mdc-dialog-content {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,11 +8,11 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
public balance: number;
|
public balance: number;
|
||||||
public currency: string;
|
public currency: string;
|
||||||
public equity: number;
|
public equity: number;
|
||||||
|
public hasImpersonationId: boolean;
|
||||||
|
public historicalDataItems: HistoricalDataItem[];
|
||||||
|
public isLoadingChart: boolean;
|
||||||
public name: string;
|
public name: string;
|
||||||
public orders: OrderWithAccount[];
|
public orders: OrderWithAccount[];
|
||||||
public platformName: string;
|
public platformName: string;
|
||||||
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||||
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit() {
|
||||||
|
this.isLoadingChart = true;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccount(this.data.accountId)
|
.fetchAccount(this.data.accountId)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -101,9 +107,46 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPerformance({
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: this.data.accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
range: 'max',
|
||||||
|
withExcludedAccounts: true
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ chart }) => {
|
||||||
|
this.historicalDataItems = chart.map(
|
||||||
|
({ date, value, valueInPercentage }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
value:
|
||||||
|
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||||
|
? valueInPercentage
|
||||||
|
: value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isLoadingChart = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.impersonationStorageService
|
||||||
|
.onChangeHasImpersonation()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((impersonationId) => {
|
||||||
|
this.hasImpersonationId = !!impersonationId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onClose(): void {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container mb-3">
|
||||||
|
<gf-investment-chart
|
||||||
|
class="h-100"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
|
[isLoading]="isLoadingChart"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
></gf-investment-chart>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
|||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
|
GfInvestmentChartModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
@ -1,3 +1,15 @@
|
|||||||
|
<div *ngIf="showActions" class="d-flex justify-content-end">
|
||||||
|
<button
|
||||||
|
class="align-items-center d-flex"
|
||||||
|
mat-stroked-button
|
||||||
|
[disabled]="dataSource?.data.length < 2"
|
||||||
|
(click)="onTransferBalance()"
|
||||||
|
>
|
||||||
|
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
|
||||||
|
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th
|
<th
|
||||||
@ -242,16 +254,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Edit</span>
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="element.isDefault || element.transactionCount > 0"
|
[disabled]="element.isDefault || element.transactionCount > 0"
|
||||||
(click)="onDeleteAccount(element.id)"
|
(click)="onDeleteAccount(element.id)"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Delete</span>
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
@Output() transferBalance = new EventEmitter<void>();
|
||||||
|
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
alert(aComment);
|
alert(aComment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTransferBalance() {
|
||||||
|
this.transferBalance.emit();
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.accountToUpdate.emit(aAccount);
|
this.accountToUpdate.emit(aAccount);
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||||
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class AdminJobsComponent implements OnDestroy, OnInit {
|
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||||
public defaultDateTimeFormat: string;
|
public defaultDateTimeFormat: string;
|
||||||
public filterForm: FormGroup;
|
public filterForm: FormGroup;
|
||||||
public jobs: AdminJobs['jobs'] = [];
|
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
|
||||||
|
new MatTableDataSource();
|
||||||
|
public displayedColumns = [
|
||||||
|
'index',
|
||||||
|
'type',
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'attempts',
|
||||||
|
'created',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
|||||||
.fetchJobs({ status: aStatus })
|
.fetchJobs({ status: aStatus })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ jobs }) => {
|
.subscribe(({ jobs }) => {
|
||||||
this.jobs = jobs;
|
this.dataSource = new MatTableDataSource(jobs);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -13,122 +13,158 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<thead>
|
<ng-container matColumnDef="index">
|
||||||
<tr class="mat-header-row">
|
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
#
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
{{ element.id }}
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
</td>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
</ng-container>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
<ng-container matColumnDef="type">
|
||||||
<th class="mat-header-cell px-1 py-2">
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
<button
|
<ng-container i18n>Type</ng-container>
|
||||||
class="mx-1 no-min-width px-2"
|
</th>
|
||||||
mat-button
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
[matMenuTriggerFor]="jobsActionsMenu"
|
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
|
||||||
(click)="$event.stopPropagation()"
|
Asset Profile
|
||||||
>
|
</ng-container>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ng-container
|
||||||
|
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Historical Market Data
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="symbol">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Symbol</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.data?.symbol }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.data?.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="attempts">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||||
|
<ng-container i18n>Attempts</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||||
|
{{ element.attemptsMade }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="created">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Created</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.timestamp | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="finished">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Finished</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.finishedOn | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Status</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'active'"
|
||||||
|
name="play-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'completed'"
|
||||||
|
class="text-success"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'delayed'"
|
||||||
|
name="time-outline"
|
||||||
|
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'failed'"
|
||||||
|
class="text-danger"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'paused'"
|
||||||
|
name="pause-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'waiting'"
|
||||||
|
name="cafe-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<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>
|
</button>
|
||||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
</mat-menu>
|
||||||
<button mat-menu-item (click)="onDeleteJobs()">
|
</th>
|
||||||
<ng-container i18n>Delete Jobs</ng-container>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
</button>
|
<button
|
||||||
</mat-menu>
|
class="mx-1 no-min-width px-2"
|
||||||
</th>
|
mat-button
|
||||||
</tr>
|
[matMenuTriggerFor]="jobActionsMenu"
|
||||||
</thead>
|
(click)="$event.stopPropagation()"
|
||||||
<tbody>
|
>
|
||||||
<ng-container *ngFor="let job of jobs">
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
<tr class="mat-row">
|
</button>
|
||||||
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<td class="mat-cell px-1 py-2">
|
<button mat-menu-item (click)="onViewData(element.data)">
|
||||||
<span class="align-items-center d-flex">
|
<ng-container i18n>View Data</ng-container>
|
||||||
<ion-icon
|
</button>
|
||||||
class="mr-1"
|
<button
|
||||||
name="arrow-down-circle-outline"
|
mat-menu-item
|
||||||
></ion-icon>
|
[disabled]="element.stacktrace?.length <= 0"
|
||||||
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
|
(click)="onViewStacktrace(element.stacktrace)"
|
||||||
<span i18n>Asset Profile</span>
|
>
|
||||||
</ng-container>
|
<ng-container i18n>View Stacktrace</ng-container>
|
||||||
<ng-container
|
</button>
|
||||||
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
<button mat-menu-item (click)="onDeleteJob(element.id)">
|
||||||
>
|
<ng-container i18n>Delete Job</ng-container>
|
||||||
<span i18n>Historical Market Data</span>
|
</button>
|
||||||
</ng-container>
|
</mat-menu>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
</ng-container>
|
||||||
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
{{ job.attemptsMade }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
{{ job.timestamp | date: defaultDateTimeFormat }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
{{ job.finishedOn | date: defaultDateTimeFormat }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'active'"
|
|
||||||
name="play-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'completed'"
|
|
||||||
class="text-success"
|
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'delayed'"
|
|
||||||
name="time-outline"
|
|
||||||
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'failed'"
|
|
||||||
class="text-danger"
|
|
||||||
name="alert-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'paused'"
|
|
||||||
name="pause-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'waiting'"
|
|
||||||
name="cafe-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="jobActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
|
||||||
<button mat-menu-item (click)="onViewData(job.data)">
|
|
||||||
<ng-container i18n>View Data</ng-container>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
[disabled]="job.stacktrace?.length <= 0"
|
|
||||||
(click)="onViewStacktrace(job.stacktrace)"
|
|
||||||
>
|
|
||||||
<ng-container i18n>View Stacktrace</ng-container>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="onDeleteJob(job.id)">
|
|
||||||
<ng-container i18n>Delete Job</ng-container>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
|
||||||
import { AdminJobsComponent } from './admin-jobs.component';
|
import { AdminJobsComponent } from './admin-jobs.component';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatTableModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -9,7 +9,11 @@
|
|||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
[symbol]="symbol"
|
[symbol]="symbol"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
<div
|
||||||
|
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
|
||||||
|
class="d-flex"
|
||||||
|
[hidden]="!marketData.length > 0"
|
||||||
|
>
|
||||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||||
<div
|
<div
|
||||||
|
@ -28,7 +28,6 @@
|
|||||||
|
|
||||||
&.today {
|
&.today {
|
||||||
background-color: rgba(var(--palette-accent-500), 1);
|
background-color: rgba(var(--palette-accent-500), 1);
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||||
|
|
||||||
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
|
||||||
return {
|
return {
|
||||||
date: format(marketDataItem.date, DATE_FORMAT),
|
date: format(date, DATE_FORMAT),
|
||||||
value: marketDataItem.marketPrice
|
value: marketPrice
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
const date = parseISO(`${yearMonth}-${day}`);
|
const date = parseISO(`${yearMonth}-${day}`);
|
||||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||||
|
|
||||||
if (isSameDay(date, new Date())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||||
data: <MarketDataDetailDialogParams>{
|
data: <MarketDataDetailDialogParams>{
|
||||||
date,
|
date,
|
||||||
@ -177,7 +173,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ withRefresh }) => {
|
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
||||||
this.marketDataChanged.next(withRefresh);
|
this.marketDataChanged.next(withRefresh);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -57,10 +57,16 @@ export class MarketDataDetailDialog implements OnDestroy {
|
|||||||
|
|
||||||
public onUpdate() {
|
public onUpdate() {
|
||||||
this.adminService
|
this.adminService
|
||||||
.putMarketData({
|
.postMarketData({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
date: this.data.date,
|
marketData: {
|
||||||
marketData: { marketPrice: this.data.marketPrice },
|
marketData: [
|
||||||
|
{
|
||||||
|
date: this.data.date.toISOString(),
|
||||||
|
marketPrice: this.data.marketPrice
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
symbol: this.data.symbol
|
symbol: this.data.symbol
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
@ -178,10 +178,20 @@ export class AdminMarketDataComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
this.adminService
|
const confirmation = confirm(
|
||||||
.deleteProfileData({ dataSource, symbol })
|
$localize`Do you really want to delete this asset profile?`
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
);
|
||||||
.subscribe(() => {});
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.adminService
|
||||||
|
.deleteProfileData({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGather7Days() {
|
public onGather7Days() {
|
||||||
@ -342,7 +352,7 @@ export class AdminMarketDataComponent
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ dataSource, symbol }) => {
|
.subscribe(({ dataSource, symbol } = {}) => {
|
||||||
if (dataSource && symbol) {
|
if (dataSource && symbol) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.addAssetProfile({ dataSource, symbol })
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
@ -143,12 +143,24 @@
|
|||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="element.activitiesCount !== 0"
|
[disabled]="element.activitiesCount !== 0"
|
||||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Delete</ng-container>
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -2,11 +2,4 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.fab-container {
|
|
||||||
bottom: 2rem;
|
|
||||||
position: fixed;
|
|
||||||
right: 2rem;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,18 +6,25 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
ScraperConfiguration,
|
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
MarketData,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { parse as csvToJson } from 'papaparse';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -31,24 +38,38 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
public assetClass: string;
|
public assetProfileClass: string;
|
||||||
|
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 assetProfile: AdminMarketDataDetails['assetProfile'];
|
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||||
public assetProfileForm = this.formBuilder.group({
|
public assetProfileForm = this.formBuilder.group({
|
||||||
|
assetClass: new FormControl<AssetClass>(undefined),
|
||||||
|
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
||||||
comment: '',
|
comment: '',
|
||||||
|
name: ['', Validators.required],
|
||||||
scraperConfiguration: '',
|
scraperConfiguration: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
});
|
});
|
||||||
public assetSubClass: string;
|
public assetProfileSubClass: string;
|
||||||
public benchmarks: Partial<SymbolProfile>[];
|
public benchmarks: Partial<SymbolProfile>[];
|
||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public historicalDataAsCsvString: string;
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||||
|
new Date(),
|
||||||
|
DATE_FORMAT
|
||||||
|
)};123.45`;
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -67,6 +88,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
|
this.historicalDataAsCsvString =
|
||||||
|
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketDataBySymbol({
|
.fetchAdminMarketDataBySymbol({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -76,8 +100,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(({ assetProfile, marketData }) => {
|
.subscribe(({ assetProfile, marketData }) => {
|
||||||
this.assetProfile = assetProfile;
|
this.assetProfile = assetProfile;
|
||||||
|
|
||||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
||||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
||||||
return id === this.assetProfile.id;
|
return id === this.assetProfile.id;
|
||||||
@ -104,7 +128,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.assetProfileForm.setValue({
|
this.assetProfileForm.setValue({
|
||||||
|
assetClass: this.assetProfile.assetClass ?? null,
|
||||||
|
assetSubClass: this.assetProfile.assetSubClass ?? null,
|
||||||
comment: this.assetProfile?.comment ?? '',
|
comment: this.assetProfile?.comment ?? '',
|
||||||
|
name: this.assetProfile.name ?? this.assetProfile.symbol,
|
||||||
scraperConfiguration: JSON.stringify(
|
scraperConfiguration: JSON.stringify(
|
||||||
this.assetProfile?.scraperConfiguration ?? {}
|
this.assetProfile?.scraperConfiguration ?? {}
|
||||||
),
|
),
|
||||||
@ -135,6 +162,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImportHistoricalData() {
|
||||||
|
const marketData = csvToJson(this.historicalDataAsCsvString, {
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}).data;
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.postMarketData({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
marketData: {
|
||||||
|
marketData: marketData.map(({ date, marketPrice }) => {
|
||||||
|
return { marketPrice, date: parseDate(date).toISOString() };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -171,9 +221,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const assetProfileData: UpdateAssetProfileDto = {
|
const assetProfileData: UpdateAssetProfileDto = {
|
||||||
|
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
||||||
|
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
||||||
|
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
||||||
|
name: this.assetProfileForm.controls['name'].value,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbolMapping,
|
symbolMapping
|
||||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
|
@ -51,6 +51,36 @@
|
|||||||
[symbol]="data.symbol"
|
[symbol]="data.symbol"
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label>
|
||||||
|
<ng-container i18n>Historical Data</ng-container> (CSV)
|
||||||
|
</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMaxRows="5"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
matInput
|
||||||
|
placeholder="e.g. 20230601;1.61"
|
||||||
|
type="text"
|
||||||
|
[ngModelOptions]="{standalone: true}"
|
||||||
|
[(ngModel)]="historicalDataAsCsvString"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-2">
|
||||||
|
<button
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
(click)="onImportHistoricalData()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Import</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
@ -82,7 +112,11 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[hidden]="!assetProfileClass"
|
||||||
|
[value]="assetProfileClass"
|
||||||
>Asset Class</gf-value
|
>Asset Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -90,8 +124,8 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[hidden]="!assetSubClass"
|
[hidden]="!assetProfileSubClass"
|
||||||
[value]="assetSubClass"
|
[value]="assetProfileSubClass"
|
||||||
>Asset Sub Class</gf-value
|
>Asset Sub Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -144,6 +178,38 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name</mat-label>
|
||||||
|
<input formControlName="name" matInput type="text" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Asset Class</mat-label>
|
||||||
|
<mat-select formControlName="assetClass">
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let assetClass of assetClasses"
|
||||||
|
[value]="assetClass.id"
|
||||||
|
>{{ assetClass.label }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Asset Sub Class</mat-label>
|
||||||
|
<mat-select formControlName="assetSubClass">
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let assetSubClass of assetSubClasses"
|
||||||
|
[value]="assetSubClass.id"
|
||||||
|
>{{ assetSubClass.label }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<mat-checkbox
|
<mat-checkbox
|
||||||
|
@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
|||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
@ -26,6 +27,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
|
MatSelectModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TextFieldModule
|
TextFieldModule
|
||||||
],
|
],
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
AbstractControl,
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
ValidationErrors,
|
||||||
Validators
|
Validators
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { MatDialogRef } from '@angular/material/dialog';
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: { class: 'h-100' },
|
host: { class: 'h-100' },
|
||||||
selector: 'gf-create-asset-profile-dialog',
|
selector: 'gf-create-asset-profile-dialog',
|
||||||
|
styleUrls: ['./create-asset-profile-dialog.component.scss'],
|
||||||
templateUrl: 'create-asset-profile-dialog.html'
|
templateUrl: 'create-asset-profile-dialog.html'
|
||||||
})
|
})
|
||||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||||
public createAssetProfileForm: FormGroup;
|
public createAssetProfileForm: FormGroup;
|
||||||
|
public mode: 'auto' | 'manual';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly adminService: AdminService,
|
public readonly adminService: AdminService,
|
||||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
|
||||||
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||||
public readonly formBuilder: FormBuilder
|
public readonly formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.createAssetProfileForm = this.formBuilder.group({
|
this.createAssetProfileForm = this.formBuilder.group(
|
||||||
searchSymbol: new FormControl(null, [Validators.required])
|
{
|
||||||
});
|
addSymbol: new FormControl(null, [Validators.required]),
|
||||||
|
searchSymbol: new FormControl(null, [Validators.required])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validators: this.atLeastOneValid
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mode = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onRadioChange(mode: 'auto' | 'manual') {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
public onSubmit() {
|
public onSubmit() {
|
||||||
this.dialogRef.close({
|
this.mode === 'auto'
|
||||||
dataSource:
|
? this.dialogRef.close({
|
||||||
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
dataSource:
|
||||||
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
this.createAssetProfileForm.controls['searchSymbol'].value
|
||||||
});
|
.dataSource,
|
||||||
|
symbol:
|
||||||
|
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||||
|
})
|
||||||
|
: this.dialogRef.close({
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
symbol: this.createAssetProfileForm.controls['addSymbol'].value
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {}
|
public ngOnDestroy() {}
|
||||||
|
|
||||||
|
private atLeastOneValid(control: AbstractControl): ValidationErrors {
|
||||||
|
const addSymbolControl = control.get('addSymbol');
|
||||||
|
const searchSymbolControl = control.get('searchSymbol');
|
||||||
|
|
||||||
|
if (addSymbolControl.valid && searchSymbolControl.valid) {
|
||||||
|
return { atLeastOneValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
addSymbolControl.valid ||
|
||||||
|
!addSymbolControl ||
|
||||||
|
searchSymbolControl.valid ||
|
||||||
|
!searchSymbolControl
|
||||||
|
) {
|
||||||
|
return { atLeastOneValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { atLeastOneValid: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,35 @@
|
|||||||
>
|
>
|
||||||
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<div class="mb-3">
|
||||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
<mat-radio-group
|
||||||
<gf-symbol-autocomplete
|
color="primary"
|
||||||
formControlName="searchSymbol"
|
[value]="mode"
|
||||||
[includeIndices]="true"
|
(change)="onRadioChange($event.value)"
|
||||||
/>
|
>
|
||||||
</mat-form-field>
|
<mat-radio-button name="auto" value="auto"></mat-radio-button>
|
||||||
|
<label class="m-0" for="auto" i18n>Search</label>
|
||||||
|
<mat-radio-button class="ml-3" name="manual" value="manual">
|
||||||
|
</mat-radio-button>
|
||||||
|
<label class="m-0" for="manual" i18n>Add Manually</label>
|
||||||
|
</mat-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="mode === 'auto'">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
|
<gf-symbol-autocomplete
|
||||||
|
formControlName="searchSymbol"
|
||||||
|
[includeIndices]="true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="mode === 'manual'">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Symbol</mat-label>
|
||||||
|
<input formControlName="addSymbol" matInput />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
@ -20,7 +42,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="!createAssetProfileForm.valid"
|
[disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Save</ng-container>
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@ -4,6 +4,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
|
||||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||||
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatRadioModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -43,7 +42,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version: string;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -170,20 +169,20 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onReadOnlyModeChange(aEvent: MatCheckboxChange) {
|
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||||
this.putAdminSetting({
|
|
||||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
|
||||||
value: aEvent.checked ? true : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) {
|
|
||||||
this.putAdminSetting({
|
this.putAdminSetting({
|
||||||
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||||
value: aEvent.checked ? undefined : false
|
value: aEvent.checked ? undefined : false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||||
|
this.putAdminSetting({
|
||||||
|
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
value: aEvent.checked ? true : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onSetSystemMessage() {
|
public onSetSystemMessage() {
|
||||||
const systemMessage = prompt($localize`Please set your system message:`);
|
const systemMessage = prompt($localize`Please set your system message:`);
|
||||||
|
|
||||||
@ -204,15 +203,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
.subscribe(
|
||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
({ exchangeRates, settings, transactionCount, userCount, version }) => {
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.exchangeRates = exchangeRates;
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.transactionCount = transactionCount;
|
this.exchangeRates = exchangeRates;
|
||||||
this.userCount = userCount;
|
this.transactionCount = transactionCount;
|
||||||
|
this.userCount = userCount;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCouponCode(aLength: number) {
|
private generateCouponCode(aLength: number) {
|
||||||
|
@ -5,14 +5,16 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Version</div>
|
<div class="w-50" i18n>Version</div>
|
||||||
<div class="w-50">{{ version }}</div>
|
<div class="w-50">
|
||||||
|
<gf-value [value]="version" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Count</div>
|
<div class="w-50" i18n>User Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="userCount"
|
[value]="userCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -21,8 +23,8 @@
|
|||||||
<div class="w-50" i18n>Activity Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
<div *ngIf="transactionCount && userCount">
|
<div *ngIf="transactionCount && userCount">
|
||||||
@ -53,6 +55,18 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<a
|
||||||
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[queryParams]="{
|
||||||
|
assetProfileDialog: true,
|
||||||
|
dataSource: exchangeRate.dataSource,
|
||||||
|
symbol: exchangeRate.symbol
|
||||||
|
}"
|
||||||
|
[routerLink]="['/admin', 'market-data']"
|
||||||
|
>
|
||||||
|
<ion-icon name="create-outline"></ion-icon>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
@ -79,21 +93,23 @@
|
|||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Signup</div>
|
<div class="w-50" i18n>User Signup</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||||
(change)="onEnableUserSignupModeChange($event)"
|
(change)="onEnableUserSignupModeChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Read-only Mode</div>
|
<div class="w-50" i18n>Read-only Mode</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
[checked]="info?.isReadOnlyMode"
|
[checked]="info?.isReadOnlyMode"
|
||||||
(change)="onReadOnlyModeChange($event)"
|
(change)="onReadOnlyModeChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||||
|
@ -3,8 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -18,10 +19,11 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCheckboxModule,
|
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
ReactiveFormsModule
|
MatSlideToggleModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [CacheService],
|
providers: [CacheService],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -86,12 +86,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Edit</span>
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Delete</span>
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -66,12 +66,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #tagMenu="matMenu" xPosition="before">
|
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdateTag(element)">
|
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Edit</span>
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
<span i18n>Delete</span>
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
@ -20,13 +21,15 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-users.html'
|
templateUrl: './admin-users.html'
|
||||||
})
|
})
|
||||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||||
|
public dataSource: MatTableDataSource<AdminData['users'][0]> =
|
||||||
|
new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
|
public displayedColumns: string[] = [];
|
||||||
public getEmojiFlag = getEmojiFlag;
|
public getEmojiFlag = getEmojiFlag;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToImpersonateAllUsers: boolean;
|
public hasPermissionToImpersonateAllUsers: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public user: User;
|
public user: User;
|
||||||
public users: AdminData['users'];
|
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -44,6 +47,29 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.hasPermissionForSubscription) {
|
||||||
|
this.displayedColumns = [
|
||||||
|
'index',
|
||||||
|
'user',
|
||||||
|
'country',
|
||||||
|
'registration',
|
||||||
|
'accounts',
|
||||||
|
'activities',
|
||||||
|
'engagementPerDay',
|
||||||
|
'lastRequest',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
this.displayedColumns = [
|
||||||
|
'index',
|
||||||
|
'user',
|
||||||
|
'registration',
|
||||||
|
'accounts',
|
||||||
|
'activities',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -118,7 +144,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ users }) => {
|
.subscribe(({ users }) => {
|
||||||
this.users = users;
|
this.dataSource = new MatTableDataSource(users);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -2,136 +2,236 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="users">
|
<div class="users">
|
||||||
<table class="gf-table">
|
<table class="gf-table" mat-table [dataSource]="dataSource">
|
||||||
<thead>
|
<ng-container matColumnDef="index">
|
||||||
<tr class="mat-mdc-header-row">
|
<th
|
||||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">#</th>
|
*matHeaderCellDef
|
||||||
<th class="mat-mdc-header-cell px-1 py-2" i18n>User</th>
|
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||||
<th
|
mat-header-cell
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
class="mat-mdc-header-cell px-1 py-2"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Country</ng-container>
|
|
||||||
</th>
|
|
||||||
<th class="mat-mdc-header-cell px-1 py-2">
|
|
||||||
<ng-container i18n>Registration</ng-container>
|
|
||||||
</th>
|
|
||||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">
|
|
||||||
<ng-container i18n>Accounts</ng-container>
|
|
||||||
</th>
|
|
||||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">
|
|
||||||
<ng-container i18n>Activities</ng-container>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Engagement per Day</ng-container>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
class="mat-mdc-header-cell px-1 py-2"
|
|
||||||
i18n
|
|
||||||
>
|
|
||||||
Last Request
|
|
||||||
</th>
|
|
||||||
<th class="mat-mdc-header-cell px-1 py-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
*ngFor="let userItem of users; let i = index"
|
|
||||||
class="mat-mdc-row"
|
|
||||||
>
|
>
|
||||||
<td class="mat-mdc-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
#
|
||||||
<td class="mat-mdc-cell px-1 py-2">
|
</th>
|
||||||
<div class="d-flex align-items-center">
|
<td
|
||||||
<span class="d-none d-sm-inline-block text-monospace"
|
*matCellDef="let element; let i=index"
|
||||||
>{{ userItem.id }}</span
|
class="mat-mdc-cell px-1 py-2 text-right"
|
||||||
>
|
mat-cell
|
||||||
<span class="d-inline-block d-sm-none text-monospace"
|
>
|
||||||
>{{ (userItem.id | slice:0:5) + '...' }}</span
|
{{ i + 1 }}
|
||||||
>
|
</td>
|
||||||
<gf-premium-indicator
|
</ng-container>
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
|
||||||
class="ml-1"
|
<ng-container matColumnDef="user">
|
||||||
[enableLink]="false"
|
<th
|
||||||
[title]="'Expires ' + formatDistanceToNow(userItem.subscription.expiresAt) + ' (' + (userItem.subscription.expiresAt | date: defaultDateFormat) + ')'"
|
*matHeaderCellDef
|
||||||
></gf-premium-indicator>
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
</div>
|
i18n
|
||||||
</td>
|
mat-header-cell
|
||||||
<td
|
>
|
||||||
*ngIf="hasPermissionForSubscription"
|
User
|
||||||
class="mat-mdc-cell px-1 py-2"
|
</th>
|
||||||
>
|
<td
|
||||||
<span class="h5" [title]="userItem.country"
|
*matCellDef="let element"
|
||||||
>{{ getEmojiFlag(userItem.country) }}</span
|
class="mat-mdc-cell px-1 py-2"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="d-none d-sm-inline-block text-monospace"
|
||||||
|
>{{ element.id }}</span
|
||||||
>
|
>
|
||||||
</td>
|
<span class="d-inline-block d-sm-none text-monospace"
|
||||||
<td class="mat-mdc-cell px-1 py-2">
|
>{{ (element.id | slice:0:5) + '...' }}</span
|
||||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
>
|
||||||
</td>
|
<gf-premium-indicator
|
||||||
<td class="mat-mdc-cell px-1 py-2 text-right">
|
*ngIf="element?.subscription?.type === 'Premium'"
|
||||||
<gf-value
|
class="ml-1"
|
||||||
class="d-inline-block justify-content-end"
|
[enableLink]="false"
|
||||||
[locale]="user?.settings?.locale"
|
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
|
||||||
[value]="userItem.accountCount"
|
></gf-premium-indicator>
|
||||||
></gf-value>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-mdc-cell px-1 py-2 text-right">
|
</ng-container>
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
<ng-container
|
||||||
[locale]="user?.settings?.locale"
|
*ngIf="hasPermissionForSubscription"
|
||||||
[value]="userItem.transactionCount"
|
matColumnDef="country"
|
||||||
></gf-value>
|
>
|
||||||
</td>
|
<th
|
||||||
<td
|
*matHeaderCellDef
|
||||||
*ngIf="hasPermissionForSubscription"
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
class="mat-mdc-cell px-1 py-2 text-right"
|
mat-header-cell
|
||||||
|
>
|
||||||
|
<ng-container i18n>Country</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<span class="h5" [title]="element.country"
|
||||||
|
>{{ getEmojiFlag(element.country) }}</span
|
||||||
>
|
>
|
||||||
<gf-value
|
</td>
|
||||||
class="d-inline-block justify-content-end"
|
</ng-container>
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[precision]="0"
|
<ng-container matColumnDef="registration">
|
||||||
[value]="userItem.engagement"
|
<th
|
||||||
></gf-value>
|
*matHeaderCellDef
|
||||||
</td>
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
<td
|
mat-header-cell
|
||||||
*ngIf="hasPermissionForSubscription"
|
>
|
||||||
class="mat-mdc-cell px-1 py-2"
|
<ng-container i18n>Registration</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
{{ formatDistanceToNow(element.createdAt) }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="accounts">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||||
|
mat-header-cell
|
||||||
|
>
|
||||||
|
<ng-container i18n>Accounts</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="element.accountCount"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activities">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||||
|
mat-header-cell
|
||||||
|
>
|
||||||
|
<ng-container i18n>Activities</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="element.transactionCount"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
matColumnDef="engagementPerDay"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||||
|
mat-header-cell
|
||||||
|
>
|
||||||
|
<ng-container i18n>Engagement per Day</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
|
[value]="element.engagement"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
matColumnDef="lastRequest"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
|
i18n
|
||||||
|
mat-header-cell
|
||||||
|
>
|
||||||
|
Last Request
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
{{ formatDistanceToNow(element.lastActivity) }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
|
mat-header-cell
|
||||||
|
></th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="mat-mdc-cell px-1 py-2"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="userMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
{{ formatDistanceToNow(userItem.lastActivity) }}
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</td>
|
</button>
|
||||||
<td class="mat-mdc-cell px-1 py-2">
|
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
*ngIf="hasPermissionToImpersonateAllUsers"
|
||||||
mat-button
|
mat-menu-item
|
||||||
[matMenuTriggerFor]="userMenu"
|
(click)="onImpersonateUser(element.id)"
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
</button>
|
|
||||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
|
||||||
<button
|
|
||||||
*ngIf="hasPermissionToImpersonateAllUsers"
|
|
||||||
mat-menu-item
|
|
||||||
(click)="onImpersonateUser(userItem.id)"
|
|
||||||
>
|
|
||||||
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
|
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
|
||||||
<span i18n>Impersonate User</span>
|
<span i18n>Impersonate User</span>
|
||||||
</button>
|
</span>
|
||||||
<button
|
</button>
|
||||||
mat-menu-item
|
<button
|
||||||
[disabled]="userItem.id === user?.id"
|
mat-menu-item
|
||||||
(click)="onDeleteUser(userItem.id)"
|
[disabled]="element.id === user?.id"
|
||||||
>
|
(click)="onDeleteUser(element.id)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
<span i18n>Delete User</span>
|
<span i18n>Delete User</span>
|
||||||
</button>
|
</span>
|
||||||
</mat-menu>
|
</button>
|
||||||
</td>
|
</mat-menu>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</ng-container>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
*matHeaderRowDef="displayedColumns"
|
||||||
|
class="mat-mdc-header-row"
|
||||||
|
mat-header-row
|
||||||
|
></tr>
|
||||||
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="mat-mdc-row"
|
||||||
|
mat-row
|
||||||
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
|
|||||||
GfPremiumIndicatorModule,
|
GfPremiumIndicatorModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule
|
MatMenuModule,
|
||||||
|
MatTableModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
<button
|
<button
|
||||||
*ngIf="deviceType === 'mobile'"
|
*ngIf="deviceType === 'mobile'"
|
||||||
class="mt-2"
|
|
||||||
mat-button
|
mat-button
|
||||||
(click)="onClickCloseButton()"
|
(click)="onClickCloseButton()"
|
||||||
>
|
>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-bottom: 0;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0 !important;
|
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,6 +110,34 @@
|
|||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
|
||||||
|
<button
|
||||||
|
#assistantTrigger="matMenuTrigger"
|
||||||
|
class="h-100 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[mat-menu-trigger-for]="assistantMenu"
|
||||||
|
[matMenuTriggerRestoreFocus]="false"
|
||||||
|
(menuOpened)="onOpenAssistant()"
|
||||||
|
>
|
||||||
|
<ion-icon name="search-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#assistantMenu="matMenu"
|
||||||
|
class="assistant"
|
||||||
|
xPosition="before"
|
||||||
|
[overlapTrigger]="true"
|
||||||
|
(closed)="assistantElement?.setIsOpen(false)"
|
||||||
|
>
|
||||||
|
<gf-assistant
|
||||||
|
#assistant
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[hasPermissionToAccessAdminControl]="
|
||||||
|
hasPermissionToAccessAdminControl
|
||||||
|
"
|
||||||
|
(closed)="closeAssistant()"
|
||||||
|
/>
|
||||||
|
</mat-menu>
|
||||||
|
</li>
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<button
|
<button
|
||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
@ -272,7 +300,7 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'font-weight-bold': currentRoute === routeFeatures,
|
'font-weight-bold': currentRoute === routeFeatures,
|
||||||
'text-decoration-underline': currentRoute === routeFeatuers
|
'text-decoration-underline': currentRoute === routeFeatures
|
||||||
}"
|
}"
|
||||||
[routerLink]="routerLinkFeatures"
|
[routerLink]="routerLinkFeatures"
|
||||||
>Features</a
|
>Features</a
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mdc-button {
|
.mdc-button {
|
||||||
height: unset;
|
height: 100%;
|
||||||
|
|
||||||
&:not(.mat-primary) {
|
&:not(.mat-primary) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -2,11 +2,14 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
Output
|
Output,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
styleUrls: ['./header.component.scss']
|
styleUrls: ['./header.component.scss']
|
||||||
})
|
})
|
||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
|
@HostListener('window:keydown', ['$event'])
|
||||||
|
openAssistantWithHotKey(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === '/' &&
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'input' &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
|
||||||
|
this.hasPermissionToAccessAssistant
|
||||||
|
) {
|
||||||
|
this.assistantElement.setIsOpen(true);
|
||||||
|
this.assistentMenuTriggerElement.openMenu();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
@Input() hasTabs: boolean;
|
@Input() hasTabs: boolean;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
@Input() pageTitle: string;
|
@Input() pageTitle: string;
|
||||||
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
|
|||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('assistant') assistantElement: AssistantComponent;
|
||||||
|
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
|
||||||
|
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
|
public hasPermissionToAccessAssistant: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessAssistant = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public closeAssistant() {
|
||||||
|
this.assistentMenuTriggerElement?.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
if (aId) {
|
if (aId) {
|
||||||
this.impersonationStorageService.setId(aId);
|
this.impersonationStorageService.setId(aId);
|
||||||
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.isMenuOpen = true;
|
this.isMenuOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenAssistant() {
|
||||||
|
this.assistantElement.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
|
|||||||
exports: [HeaderComponent],
|
exports: [HeaderComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAssistantModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LoginWithAccessTokenDialogModule,
|
LoginWithAccessTokenDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -81,8 +81,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe((impersonationId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!impersonationId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChangeDateRange(dateRange: DateRange) {
|
public onChangeDateRange(dateRange: DateRange) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="container justify-content-center p-3">
|
<div class="container justify-content-center p-3">
|
||||||
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
|
<div class="mb-3 text-center">
|
||||||
<gf-toggle
|
<gf-toggle
|
||||||
[defaultValue]="user?.settings?.dateRange"
|
[defaultValue]="user?.settings?.dateRange"
|
||||||
[isLoading]="positions === undefined"
|
[isLoading]="positions === undefined"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
||||||
<div class="mb-5 row">
|
<div *ngIf="hasPermissionToAccessFearAndGreedIndex" class="mb-5 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||||
@ -8,15 +8,15 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
|
||||||
yMin="0"
|
|
||||||
[colorScheme]="user?.settings?.colorScheme"
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[isAnimated]="true"
|
[isAnimated]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[yMax]="100"
|
||||||
[yMaxLabel]="greedLabel"
|
[yMaxLabel]="greedLabel"
|
||||||
|
[yMin]="0"
|
||||||
[yMinLabel]="fearLabel"
|
[yMinLabel]="fearLabel"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<gf-fear-and-greed-index
|
<gf-fear-and-greed-index
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [PortfolioPerformanceComponent],
|
declarations: [PortfolioPerformanceComponent],
|
||||||
exports: [PortfolioPerformanceComponent],
|
exports: [PortfolioPerformanceComponent],
|
||||||
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
|
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfPortfolioPerformanceModule {}
|
export class GfPortfolioPerformanceModule {}
|
||||||
|
@ -155,16 +155,18 @@
|
|||||||
[isDate]="true"
|
[isDate]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="firstBuyDate"
|
[value]="firstBuyDate"
|
||||||
>First Buy Date</gf-value
|
>First Activity</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
|
||||||
size="medium"
|
size="medium"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
>Transactions</gf-value
|
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
|
||||||
|
><ng-container *ngIf="transactionCount !== 1"
|
||||||
|
>Activities</ng-container
|
||||||
|
></gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
@ -213,11 +215,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Sectors</div>
|
<div class="h5" i18n>Sectors</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="sectors"
|
[positions]="sectors"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
@ -225,11 +227,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Countries</div>
|
<div class="h5" i18n>Countries</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="countries"
|
[positions]="countries"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
|
@ -4,7 +4,9 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||||
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'create-or-update-access-dialog.html'
|
templateUrl: 'create-or-update-access-dialog.html'
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||||
|
public accessForm: FormGroup;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
|
||||||
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
|
private formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {}
|
ngOnInit() {
|
||||||
|
this.accessForm = this.formBuilder.group({
|
||||||
|
alias: [this.data.access.alias],
|
||||||
|
type: [this.data.access.type, Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
const access: CreateAccessDto = {
|
||||||
|
alias: this.accessForm.controls['alias'].value,
|
||||||
|
type: this.accessForm.controls['type'].value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dialogRef.close({ access });
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -1,33 +1,38 @@
|
|||||||
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="accessForm"
|
||||||
|
(keyup.enter)="accessForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
<h1 i18n mat-dialog-title>Grant access</h1>
|
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Alias</mat-label>
|
<mat-label i18n>Alias</mat-label>
|
||||||
<input
|
<input
|
||||||
|
formControlName="alias"
|
||||||
matInput
|
matInput
|
||||||
name="alias"
|
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="data.access.alias"
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
<mat-select name="type" required [(value)]="data.access.type">
|
<mat-select formControlName="type">
|
||||||
<mat-option i18n value="PUBLIC">Public</mat-option>
|
<mat-option i18n value="PUBLIC">Public</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccessForm.form.valid"
|
type="submit"
|
||||||
[mat-dialog-close]="data"
|
[disabled]="!accessForm.valid"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Save</ng-container>
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user