Compare commits

..

90 Commits

Author SHA1 Message Date
60b2115e3b Release 2.13.0 (#2514) 2023-10-20 08:24:49 +02:00
e7956943ba Make holdings request only once (#2453)
* Make holdings request only once

* Update changelog
2023-10-20 08:21:23 +02:00
f66edf8de0 Add membership card component (#2507)
* Add membership card component

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-20 08:18:34 +02:00
29028a81f5 Add i18n service to query XML files (#2503)
* Add i18n service to query XML files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 17:13:40 +02:00
c9878c9050 Migrated users table in Admin Control to mat-table (#2469)
* Migrated users table in Admin Control to mat-table

* Update changelog
2023-10-19 16:58:01 +02:00
73ac4b4197 Add chart to account detail dialog (#2502)
* Add chart to account detail dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 15:51:31 +02:00
016634a77f Feature/setup i18n page (#2508)
* Setup i18n page

* Add meta description
2023-10-18 17:35:07 +02:00
ea65dc5034 Release 2.12.0 (#2505) 2023-10-17 20:52:02 +02:00
84db54babd Change checkboxes to slide toggles on user settings page (#2497)
* Change checkboxes to slide toggles on user settings page

* Update changelog
2023-10-17 20:49:54 +02:00
653c9c62a8 Sort imports (#2490) 2023-10-17 20:42:32 +02:00
74278073b3 Bugfix/fix query to get asset profiles matching data source and symbol (#2504)
* Match dataSource and symbol

* Update changelog
2023-10-17 20:36:01 +02:00
0375b938a2 Add confirmation dialog (#2501) 2023-10-17 20:14:46 +02:00
32df7620d9 Add support for creating asset profiles with MANUAL data source (#2479)
* Add support for creating asset profiles with MANUAL data source

* Refactoring

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-17 18:33:22 +02:00
8492a8fed0 Upgrade simplewebauthn (#2498)
* Upgrade simplewebauthn to new major version

* Update changelog
2023-10-17 09:17:44 +02:00
30e561c06f Feature/extend assistant with search for asset profile (#2499)
* Extend assistant with search for asset profile

* Extend search results by currency, symbol and asset sub class

* Update changelog
2023-10-16 21:54:36 +02:00
7243090c0e Feature/copy client locales to assets of server (#2493)
* Copy client locales to server’s assets

* Update changelog
2023-10-15 18:08:44 +02:00
7ae49eb839 Add endpoint for account balances (#2484)
* Add endpoint for account balances

* Update changelog

---------

Co-authored-by: Pavol Kolcun <pavol.kolcun@student.tuke.sk>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-15 17:09:47 +02:00
bf816c3b89 Bugfix/show transfer balance button based on permission (#2489)
* Show button depending on permission

* Update changelog
2023-10-15 10:02:55 +02:00
20f9225daa Release 2.11.0 (#2488) 2023-10-14 19:12:37 +02:00
b6101c6375 Feature/import historical data (#2448)
* Import historical data for an asset

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-14 19:06:27 +02:00
e1022846b9 Bugfix/fix displayed currency of cash balance in account dialog (#2480)
* Fix currency

* Update changelog
2023-10-14 15:01:29 +02:00
9ba79f6721 Improve style (#2478) 2023-10-13 22:11:05 +02:00
0ac97bd112 Transfer cash balance between accounts (#2455)
* Transfer cash balance between accounts

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-13 21:46:49 +02:00
827270704a Bugfix/fix import for activities of type fee and interest (#2474)
* Fix import for activities of type fee and interest

* Update changelog
2023-10-13 19:51:02 +02:00
8634463597 Feature/upgrade prisma to version 5.4.2 (#2477)
* Upgrade prisma to version 5.4.2

* Update changelog
2023-10-13 19:50:06 +02:00
3905782ad6 Fix fab-container (#2476) 2023-10-13 19:49:30 +02:00
5db984ffef Add date column to benchmark component (#2466)
* Add date column to benchmark component
2023-10-12 10:21:00 +02:00
fb3cd4b689 Remove icon (#2467) 2023-10-11 09:58:18 +02:00
3b5a34f6f3 Use fab-button in access management tab (#2456)
* Use fab-button in access management tab

* Refactor fab container

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-11 09:57:35 +02:00
22b43b5bfc Feature/extract locales 20231010 (#2462)
* Update locales

* Update changelog
2023-10-10 20:17:45 +02:00
6c66033eb4 Add date to markets overview by benchmarks (#2436)
* Add date

* Update changelog
2023-10-10 17:31:53 +02:00
162fc25e23 Release 2.10.0 (#2459) 2023-10-09 20:31:34 +02:00
45f385a483 Feature/improve symbol conversion in eod historical data service (#2457)
* Improve conversion of currency symbols

* Update changelog
2023-10-09 20:29:56 +02:00
e9ef911548 Feature/improve search results display in assistant (#2458)
* Only show search results if search is active

* Update changelog
2023-10-09 20:28:39 +02:00
d8d4d8f001 Change jobs table in admin control to mat-table (#2444)
* Change jobs table in admin control to mat-table

* Update changelog
2023-10-09 19:38:33 +02:00
f47c7313af Support enter key press to submit access dialog form (#2437)
* Support enter key press to submit access dialog form

* Update changelog
2023-10-09 19:11:09 +02:00
31f0056a2d Release 2.9.0 (#2454) 2023-10-08 20:34:39 +02:00
550e646079 Feature/introduce assistant (#2451)
* Introduce assistant

* Update changelog
2023-10-08 20:32:00 +02:00
37ff7acf04 Change platform control from select to autocomplete in account dialog (#2429)
* Change platform control from select to autocomplete in account dialog

* Update changelog
2023-10-08 19:17:55 +02:00
8236091477 Feature/add support for search query in portfolio position endpoint (#2443)
* Introduce search query filter

* Update changelog
2023-10-07 19:30:28 +02:00
2a71cb66de Use port numbers from environment variables in docker-compose.dev.yml (#2406) 2023-10-07 17:18:13 +02:00
e60fe48fdd Add dialog for cash transfer between accounts (#2433)
* Add dialog for cash transfer between accounts

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 14:46:13 +02:00
d40bc5070a Feature/remove permission to markets overview on home page (#2441)
* Remove show condition for markets overview

* Update changelog
2023-10-07 09:15:54 +02:00
fda4e0ea7d Use application version from API endpoint in Admin Control panel (#2427)
* Use application version from API endpoint

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 09:11:32 +02:00
08d696ce33 Align ok.json with ok.csv (#2439) 2023-10-07 08:44:51 +02:00
46614a7c24 Create carousel component for testimonials (#2394)
* Create carousel component for testimonials

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-06 22:12:09 +02:00
02b433eb1e Prevent empty form submission in account dialog (#2428)
* Prevent empty form submission in account dialog
2023-10-05 21:02:35 +02:00
25112a450b Add support for comment in csv import (#2416)
* Add support for comment in csv import (activities)

* Update changelog
2023-10-05 20:42:35 +02:00
727340748b Clean up imports (#2411) 2023-10-05 20:40:31 +02:00
8ad6492477 Feature/various improvements in client (#2434)
* Various improvements

* Update changelog
2023-10-05 20:31:00 +02:00
4af76f6f6d Fix hasNotDefinedValuesInObject() in object.helper.ts (#2421) 2023-10-05 20:29:34 +02:00
10940214a5 Update OSS Friends (#2431) 2023-10-05 20:27:39 +02:00
d9a6c22e1e Add application version to admin endpoint (#2423)
* Add application version to admin endpoint

* Update changelog
2023-10-04 16:15:08 +02:00
692309988c Add Prisma Studio (#2415) 2023-10-04 08:48:50 +02:00
42a54263f9 Release 2.8.0 (#2420) 2023-10-03 20:10:29 +02:00
4fb88859b2 Improve form in account dialog (#2408)
* Improve form in account dialog

* Update changelog
2023-10-03 19:34:04 +02:00
aa24b5e8c6 Feature/reload platform and tag on change (#2417)
* Reload platforms via info

* Reload tags via info

* Update changelog
2023-10-03 18:58:23 +02:00
90e18338f6 Change UX to set an asset profile as a benchmark (#2409)
* Add checkbox functionality to set / unset benchmark

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-02 20:50:12 +02:00
ad5ae938ef Feature/harmonize settings icon of user account page (#2412)
* Harmonize icon

* Update changelog
2023-10-02 11:29:33 +02:00
c9a8dd4958 Support copy-assets Nx target on Windows (#2410)
* Introduce shx plugin
2023-10-02 10:48:36 +02:00
f1ec5e704e Add version to Overview of Admin Control panel (#2414)
* Add version to Overview of Admin Control panel

* Update changelog
2023-10-02 10:05:01 +02:00
f40f0653c2 Add request params (ship, take) for pagination to GET order endpoint (#2382)
* Add request params (ship, take) for pagination to GET order endpoint

* Update changelog
2023-10-02 08:54:21 +02:00
5f7a230fd3 Bugfix/fix sidebar on user account page (#2413)
* Fix sidebar on user account page

* Update changelog
2023-10-01 19:11:48 +02:00
71feb531e8 Release 2.7.0 (#2404) 2023-09-30 08:26:04 +02:00
ec3552d7f6 Feature/add tabs to user account page (#2396)
* Create components for access, membership and settings

* Add tabs

* Update changelog
2023-09-30 08:24:26 +02:00
41875e70d6 Extend personal finance tools (#2403) 2023-09-30 08:24:00 +02:00
5fa0540936 Feature/add emergency fund setup to static portfolio analysis rules (#2400)
* Add new static portfolio analysis rule: Emergency fund setup

* Update changelog
2023-09-30 08:08:20 +02:00
5b69dee246 Feature/setup inter font family (#2402)
* Setup Inter font family

* Update changelog
2023-09-30 08:06:01 +02:00
19b0fe04a6 Feature/upgrade yahoo finance2 to version 2.8.0 (#2401)
* Upgrade yahoo-finance2 to version 2.8.0

* Update changelog
2023-09-30 07:31:02 +02:00
19ea4479ff Bugfix/fix link on features page (#2398)
* Fix link

* Update changelog
2023-09-30 07:11:44 +02:00
0b2f6a312c Sort imports (#2399) 2023-09-30 07:11:10 +02:00
f79d60014b Release 2.6.0 (#2395) 2023-09-26 18:58:22 +02:00
5b7409d08e Feature/add tag management in admin control panel (#2389)
* Add tag management

* Update locales

* Update changelog
2023-09-26 18:56:09 +02:00
6230aa87e2 Feature/add hacktoberfest 2023 blog post (#2359)
* Add blog post: Hacktoberfest 2023

* Update changelog
2023-09-26 16:08:17 +02:00
8b615d2f56 Feature/upgrade prettier to version 3.0.3 (#2393)
* Upgrade prettier to version 3.0.3

* Update changelog
2023-09-26 15:07:20 +02:00
4100446cac Update OSS Friends (#2385) 2023-09-26 15:05:14 +02:00
ad3e6d637c Improve file name (#2391) 2023-09-26 15:04:32 +02:00
aa87262954 Feature/upgrade yahoo finance2 to version 2.7.0 (#2392)
* Upgrade yahoo-finance2 to version 2.7.0

* Update changelog
2023-09-26 07:51:27 +02:00
01b6bb5b99 Clean up (#2390) 2023-09-25 23:37:52 +02:00
884b7f4de7 Clean up (#2342) 2023-09-24 08:25:25 +02:00
3f8a2b47f9 Release 2.5.0 (#2380) 2023-09-23 20:11:46 +02:00
e2e4c9be3c Feature/skip data gathering for manual data source (#2379)
* Skip data gathering

* Update changelog
2023-09-23 20:10:08 +02:00
0f7c6ff0fe Bugfix/fix asset class of cash position for empty account (#2378)
* Fix assetClass and assetSubClass

* Update changelog
2023-09-23 19:52:28 +02:00
703a96f4db Add guard (#2377) 2023-09-23 19:45:15 +02:00
42c0560422 Feature/translate activity type (#2376)
* Introduce ActivityTypeComponent with localized label

* Update changelog
2023-09-23 16:44:03 +02:00
eb63802d01 Feature/extend supported date formats in activities import (#2362)
* Extend supported date formats in activities import

* Update changelog
2023-09-23 16:14:54 +02:00
6d9191a46f Feature/setup turkish (#2300)
* Setup Turkish

* Add Turkish translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2023-09-22 20:26:45 +02:00
6744245d8b Feature/extend personal finance tools pages 20230922 (#2369)
* Extend pages

* Refactoring
2023-09-22 20:04:40 +02:00
8f64a77a9d Clean up (#2329) 2023-09-21 19:56:31 +02:00
0d5fc7655b Improve wording (#2358) 2023-09-21 19:55:36 +02:00
270 changed files with 25297 additions and 4100 deletions

View File

@ -5,6 +5,155 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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 servers 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
### Added
- Supported enter key press to submit the form of the create or update account dialog
- Added the application version to the admin control panel
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
### Changed
- Harmonized the settings icon of the user account page
- Improved the usability to set an asset profile as a benchmark
- Reload platforms after making a change in the admin control panel
- Reload tags after making a change in the admin control panel
### Fixed
- Fixed the sidebar navigation on the user account page
## 2.7.0 - 2023-09-30
### Added
- Added a new static portfolio analysis rule: Emergency fund setup
- Added tabs to the user account page
### Changed
- Set up the _Inter_ font family
- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0`
### Fixed
- Fixed a link on the features page
## 2.6.0 - 2023-09-26
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
## 2.5.0 - 2023-09-23
### Added
- Added support for translated activity types in the activities table
- Added support for dates in `DD.MM.YYYY` format in the activities import
- Set up the language localization for Türkçe (`tr`)
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
### Fixed
- Fixed an issue with the cash position in the holdings table
## 2.4.0 - 2023-09-19
### Added
@ -13,13 +162,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 accounts currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
- Fixed a memory leak related to the servers timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17
@ -170,7 +319,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 assets official one
- Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
@ -677,7 +826,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Persisted today's market data continuously
- Persisted todays market data continuously
### Fixed
@ -911,7 +1060,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout
- Considered the users language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
@ -2585,7 +2734,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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 todays data point of historical data in the admin control panel
- Restructured the server modules
### Fixed

View File

@ -18,6 +18,12 @@
### Prisma
#### Access database via GUI
Run `yarn database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`

View File

@ -27,7 +27,7 @@ New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.

View File

@ -8,4 +8,8 @@ export class CreateAccessDto {
@IsOptional()
@IsString()
granteeUserId?: string;
@IsOptional()
@IsString()
type?: 'PUBLIC';
}

View File

@ -1,8 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
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 { 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 type {
AccountWithValue,
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@ -115,6 +121,18 @@ export class AccountController {
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()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@ -154,6 +172,58 @@ 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 currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
amount: -balance,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
amount: balance,
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

View File

@ -109,7 +109,7 @@ export class AccountService {
});
}
public async getAccounts(aUserId: string) {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
@ -218,13 +218,13 @@ export class AccountService {
accountId,
amount,
currency,
date,
date = new Date(),
userId
}: {
accountId: string;
amount: number;
currency: string;
date: Date;
date?: Date;
userId: string;
}) {
const { balance, currency: currencyOfAccount } = await this.account({

View File

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class CreateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -0,0 +1,12 @@
import { IsNumber, IsString } from 'class-validator';
export class TransferBalanceDto {
@IsString()
accountIdFrom: string;
@IsString()
accountIdTo: string;
@IsNumber()
balance: number;
}

View File

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -1,9 +1,9 @@
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -254,6 +255,7 @@ export class AdminController {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -271,16 +273,10 @@ export class AdminController {
);
}
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
@ -313,6 +309,43 @@ export class AdminController {
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,
date,
marketPrice,
symbol,
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(
@ -365,8 +398,11 @@ export class AdminController {
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ dataSource, symbol });
return this.adminService.addAssetProfile({
dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
}
@Delete('profile-data/:dataSource/:symbol')

View File

@ -1,4 +1,5 @@
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -1,4 +1,5 @@
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -40,10 +41,19 @@ export class AdminService {
) {}
public async addAssetProfile({
currency,
dataSource,
symbol
}: UniqueAsset): Promise<SymbolProfile | never> {
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
currency,
dataSource,
symbol
});
}
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
@ -97,7 +107,8 @@ export class AdminService {
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
users: await this.getUsersWithAnalytics(),
version: environment.version
};
}
@ -129,10 +140,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
({ type }) => {
return type;
}
);
@ -145,6 +160,14 @@ export class AdminService {
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) {
orderBy = [{ [sortColumn]: sortDirection }];
@ -171,7 +194,9 @@ export class AdminService {
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
@ -192,7 +217,9 @@ export class AdminService {
assetSubClass,
comment,
countries,
currency,
dataSource,
name,
Order,
sectors,
symbol
@ -211,8 +238,10 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
name,
symbol,
marketDataItemCount,
sectorsCount,
@ -339,6 +368,8 @@ export class AdminService {
symbol,
assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0
};
});

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

View File

@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator';
import { IsDate, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto {
@IsDate()
@IsOptional()
date?: Date;
@IsNumber()
marketPrice: number;
}

View File

@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
SitemapModule,
SubscriptionModule,
SymbolModule,
TagModule,
TwitterBotModule,
UserModule
],

View File

@ -64,7 +64,7 @@ export class WebAuthService {
}
};
const options = generateRegistrationOptions(opts);
const options = await generateRegistrationOptions(opts);
await this.userService.updateUser({
data: {
@ -88,10 +88,16 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge,
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);
} catch (error) {
@ -117,8 +123,8 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey,
credentialId: credentialID,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } }
});
}
@ -152,7 +158,7 @@ export class WebAuthService {
userVerification: 'preferred'
};
const options = generateAuthenticationOptions(opts);
const options = await generateAuthenticationOptions(opts);
await this.userService.updateUser({
data: {
@ -181,7 +187,6 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
@ -189,9 +194,16 @@ export class WebAuthService {
},
expectedChallenge: `${user.authChallenge}`,
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) {
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });

View File

@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -32,32 +33,6 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
@ -94,4 +69,70 @@ export class BenchmarkController {
);
}
}
@Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
}

View File

@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = [];
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) {
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh,
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh
}
}
@ -245,6 +245,43 @@ export class BenchmarkService {
};
}
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return null;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}

View File

@ -55,12 +55,8 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = (
await this.platformService.getPlatforms({
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
})
).map(({ id, name }) => {
return { id, name };
});
let systemMessage: string;

View File

@ -89,7 +89,9 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
@Query('skip') skip?: number,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -105,6 +107,8 @@ export class OrderController {
filters,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
@ -147,8 +151,9 @@ export class OrderController {
userId: this.request.user.id
});
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,

View File

@ -123,6 +123,7 @@ export class OrderService {
};
}
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
this.dataGatheringService.addJobToQueue({
data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
@ -137,6 +138,7 @@ export class OrderService {
})
}
});
}
delete data.accountId;
delete data.assetClass;
@ -228,6 +230,8 @@ export class OrderService {
public async getOrders({
filters,
includeDrafts = false,
skip,
take = Number.MAX_SAFE_INTEGER,
types,
userCurrency,
userId,
@ -235,6 +239,8 @@ export class OrderService {
}: {
filters?: Filter[];
includeDrafts?: boolean;
skip?: number;
take?: number;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
@ -313,6 +319,8 @@ export class OrderService {
return (
await this.orders({
skip,
take,
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -47,6 +47,7 @@ export class PlatformController {
StatusCodes.FORBIDDEN
);
}
return this.platformService.createPlatform(data);
}

View File

@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
public async getPlatform(
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
@ -56,12 +68,6 @@ export class PlatformService {
});
}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async updatePlatform({
data,
where
@ -74,10 +80,4 @@ export class PlatformService {
where
});
}
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
}

View File

@ -173,8 +173,14 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH'
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
@ -385,12 +391,14 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});

View File

@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -50,13 +51,13 @@ import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
Account,
Type as ActivityType,
AssetClass,
DataSource,
Order,
Platform,
Prisma,
Tag,
Type as ActivityType
Tag
} from '@prisma/client';
import Big from 'big.js';
import {
@ -1013,6 +1014,9 @@ export class PortfolioService {
filters?: Filter[];
impersonationId: string;
}): 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 { portfolioOrders, transactionPoints } =
@ -1041,9 +1045,9 @@ export class PortfolioService {
const currentPositions =
await portfolioCalculator.getCurrentPositions(startDate);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
let positions = currentPositions.positions.filter(({ quantity }) => {
return !quantity.eq(0);
});
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return {
@ -1066,12 +1070,25 @@ export class PortfolioService {
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 {
hasErrors: currentPositions.hasErrors,
positions: positions.map((position) => {
return {
...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
@ -1214,12 +1231,6 @@ export class PortfolioService {
userId
});
if (isEmpty(orders)) {
return {
rules: {}
};
}
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
@ -1228,7 +1239,9 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(portfolioStart);
@ -1249,9 +1262,13 @@ export class PortfolioService {
userId
});
const userSettings = <UserSettings>this.request.user.Settings.settings;
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
accountClusterRisk: isEmpty(orders)
? undefined
: await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
@ -1262,9 +1279,11 @@ export class PortfolioService {
accounts
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
),
currencyClusterRisk: await this.rulesService.evaluate(
currencyClusterRisk: isEmpty(orders)
? undefined
: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
@ -1275,7 +1294,16 @@ export class PortfolioService {
positions
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
),
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
@ -1285,7 +1313,7 @@ export class PortfolioService {
this.getFees({ userCurrency, activities: orders }).toNumber()
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
)
}
};

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

View File

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

View File

@ -163,6 +163,13 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);

View File

@ -58,6 +58,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -74,6 +78,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -142,6 +150,14 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -254,6 +270,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -292,6 +312,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -308,6 +332,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -376,6 +404,14 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -558,6 +594,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -574,6 +614,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -642,6 +686,14 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -670,6 +722,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -686,6 +742,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -754,6 +814,14 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -868,4 +936,8 @@
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset>

View File

@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
if (aObject[key] === null || aObject[key] === null) {
if (aObject[key] === null || aObject[key] === undefined) {
return true;
} else if (isObject(aObject[key])) {
return hasNotDefinedValuesInObject(aObject[key]);

View File

@ -2,6 +2,7 @@ import * as fs from 'fs';
import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
@ -11,19 +12,11 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const descriptions = {
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, ETFs 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.'
};
const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {};
try {
@ -79,6 +72,10 @@ const locales = {
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
}
};
@ -125,7 +122,10 @@ export const HtmlTemplateMiddleware = async (
languageCode,
path,
rootUrl,
description: descriptions[languageCode],
description: i18nService.getTranslation({
languageCode,
id: 'metaDescription'
}),
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title

View File

@ -1,4 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import {
PortfolioDetails,
@ -6,16 +7,18 @@ import {
UserSettings
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts']
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Investment'
});
this.accounts = accounts;
}
public evaluate(ruleSettings: Settings) {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts']
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Single Account'
});
this.accounts = accounts;
}
public evaluate() {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Investment: Base Currency'
});
this.positions = positions;
}
public evaluate(ruleSettings: Settings) {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Investment'
});
this.positions = positions;
}
public evaluate(ruleSettings: Settings) {

View File

@ -0,0 +1,46 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
emergencyFund: number
) {
super(exchangeRateDataService, {
name: 'Emergency Fund: Set up'
});
this.emergencyFund = emergencyFund;
}
public evaluate(ruleSettings: Settings) {
if (this.emergencyFund > ruleSettings.threshold) {
return {
evaluation: 'An emergency fund has been set up',
value: true
};
}
return {
evaluation: 'No emergency fund has been set up',
value: false
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
}

View File

@ -1,22 +1,29 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private totalInvestment: number,
private fees: number
totalInvestment: number,
fees: number
) {
super(exchangeRateDataService, {
name: 'Investment'
name: 'Fee Ratio'
});
this.fees = fees;
this.totalInvestment = totalInvestment;
}
public evaluate(ruleSettings: Settings) {
const feeRatio = this.fees / this.totalInvestment;
const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
: 0;
if (feeRatio > ruleSettings.threshold) {
return {

View File

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@ -13,4 +14,29 @@ export class AccountBalanceService {
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 };
}
}

View File

@ -8,14 +8,20 @@ export class ApiService {
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
return [
@ -31,6 +37,16 @@ export class ApiService {
type: 'ASSET_CLASS'
};
}),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'
},
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,

View File

@ -127,6 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets();
}
if (uniqueAssets.length <= 0) {
return;
}
const assetProfiles =
await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles =

View File

@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
symbol = `${DEFAULT_CURRENCY}${symbol}`;
}
return symbol;
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
/**
* Converts a symbol to a EOD symbol
*
* Currency: USDCHF -> CHF.FOREX
* Currency: USDCHF -> USDCHF.FOREX
*/
private convertToEodSymbol(aSymbol: string) {
if (
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
return `${aSymbol
.replace('GBp', 'GBX')
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
let symbol = aSymbol;
symbol = symbol.replace('GBp', 'GBX');
return `${symbol}.FOREX`;
}
}

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

View File

@ -39,18 +39,22 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
public async getMax({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.findFirst({
select: {
date: true,
marketPrice: true
},
orderBy: [
{
marketPrice: 'desc'
}
],
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({

View File

@ -52,20 +52,12 @@ export class SymbolProfileService {
SymbolProfileOverrides: true
},
where: {
AND: [
{
dataSource: {
in: aUniqueAssets.map(({ dataSource }) => {
return dataSource;
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));

View File

@ -21,6 +21,7 @@
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [],
"styles": [
"apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss"
],
@ -63,6 +64,10 @@
"baseHref": "/pt/",
"localize": ["pt"]
},
"development-tr": {
"baseHref": "/tr/",
"localize": ["tr"]
},
"production": {
"fileReplacements": [
{
@ -99,40 +104,43 @@
"options": {
"commands": [
{
"command": "mkdir -p dist/apps/client"
"command": "shx mkdir -p dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets dist/apps/client"
"command": "shx cp -r apps/client/src/assets dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
},
{
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
},
{
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
},
{
"command": "cp CHANGELOG.md dist/apps/client/assets"
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
},
{
"command": "cp LICENSE dist/apps/client/assets"
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "shx cp LICENSE dist/apps/client/assets"
}
]
}
@ -165,6 +173,9 @@
"development-pt": {
"browserTarget": "client:build:development-pt"
},
"development-tr": {
"browserTarget": "client:build:development-tr"
},
"production": {
"browserTarget": "client:build:production"
}
@ -182,7 +193,8 @@
"messages.fr.xlf",
"messages.it.xlf",
"messages.nl.xlf",
"messages.pt.xlf"
"messages.pt.xlf",
"messages.tr.xlf"
]
}
},
@ -226,6 +238,10 @@
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
}
},
"sourceLocale": "en"

View File

@ -73,6 +73,11 @@ const routes: Routes = [
loadChildren: () =>
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,
loadChildren: () =>

View File

@ -32,6 +32,7 @@
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[deviceType]="deviceType"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"
@ -152,6 +153,11 @@
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
<!--
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
-->
</ul>
</div>
</div>

View File

@ -112,6 +112,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||

View File

@ -1,6 +1,6 @@
import { Platform } from '@angular/cdk/platform';
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 { MatChipsModule } from '@angular/material/chips';
import {
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
}
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [
AppRoutingModule,
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}

View File

@ -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">
<ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>();

View File

@ -3,5 +3,9 @@
.mat-mdc-dialog-content {
max-height: unset;
.chart-container {
aspect-ratio: 16 / 9;
}
}
}

View File

@ -8,11 +8,11 @@ import {
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
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 { 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 { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public balance: number;
public currency: string;
public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingChart: boolean;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {
this.userService.stateChanged
@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
});
}
public ngOnInit(): void {
public ngOnInit() {
this.isLoadingChart = true;
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
@ -101,9 +107,45 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max'
})
.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();
}

View File

@ -20,6 +20,17 @@
</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="col-6 mb-3">
<gf-value

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.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 { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,

View File

@ -1,3 +1,14 @@
<div *ngIf="showActions" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button
(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">
<ng-container matColumnDef="status">
<th

View File

@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
@Output() transferBalance = new EventEmitter<void>();
@ViewChild(MatSort) sort: MatSort;
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
alert(aComment);
}
public onTransferBalance() {
this.transferBalance.emit();
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}

View File

@ -6,6 +6,7 @@ import {
OnInit
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string;
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 user: User;
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.jobs = jobs;
this.dataSource = new MatTableDataSource(jobs);
this.changeDetectorRef.markForCheck();
});

View File

@ -13,18 +13,115 @@
</mat-select>
</mat-form-field>
</form>
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2">
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
#
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.id }}
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
Asset Profile
</ng-container>
<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
@ -39,69 +136,7 @@
</button>
</mat-menu>
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ 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">
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
@ -111,24 +146,25 @@
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">
<button mat-menu-item (click)="onViewData(element.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
[disabled]="element.stacktrace?.length <= 0"
(click)="onViewStacktrace(element.stacktrace)"
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(job.id)">
<button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { AdminJobsComponent } from './admin-jobs.component';
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatButtonModule,
MatMenuModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => {
.subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh);
});
}

View File

@ -178,10 +178,20 @@ export class AdminMarketDataComponent
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
public onGather7Days() {
@ -342,7 +352,7 @@ export class AdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })

View File

@ -2,11 +2,4 @@
:host {
display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
}

View File

@ -11,13 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -43,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public historicalDataAsCsvString: string;
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
[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>();
public constructor(
@ -67,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -135,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.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: parseISO(date) };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
@ -146,9 +179,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.dataService.updateInfo();
this.isBenchmark = true;
this.changeDetectorRef.markForCheck();
});
}
@ -185,6 +220,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.updateInfo();
this.isBenchmark = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -37,13 +37,6 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="isBenchmark"
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
</mat-menu>
</div>
@ -58,6 +51,36 @@
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></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="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
@ -151,6 +174,17 @@
</ng-template>
</ng-container>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>Benchmark</mat-checkbox
>
</div>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>

View File

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfPortfolioProportionChartModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatInputModule,
MatMenuModule,

View File

@ -1,15 +1,15 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog',
styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html'
})
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual';
public constructor(
public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({
this.createAssetProfileForm = this.formBuilder.group(
{
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required])
});
},
{
validators: this.atLeastOneValid
}
);
this.mode = 'auto';
}
public onCancel() {
this.dialogRef.close();
}
public onRadioChange(mode: 'auto' | 'manual') {
this.mode = mode;
}
public onSubmit() {
this.dialogRef.close({
this.mode === 'auto'
? this.dialogRef.close({
dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.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() {}
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 };
}
}

View File

@ -6,6 +6,21 @@
>
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div class="mb-3">
<mat-radio-group
color="primary"
[value]="mode"
(change)="onRadioChange($event.value)"
>
<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
@ -14,13 +29,20 @@
/>
</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 class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!createAssetProfileForm.valid"
[disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
>
<ng-container i18n>Save</ng-container>
</button>

View File

@ -4,6 +4,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
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 { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { environment } from '@ghostfolio/client/../environments/environment';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public transactionCount: number;
public userCount: number;
public user: User;
public version: string;
private unsubscribeSubject = new Subject<void>();
@ -202,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
.subscribe(
({ exchangeRates, settings, transactionCount, userCount, version }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;
this.changeDetectorRef.markForCheck();
});
}
);
}
private generateCouponCode(aLength: number) {

View File

@ -3,12 +3,18 @@
<div class="col">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="d-flex my-3">
<div class="w-50" i18n>Version</div>
<div class="w-50">
<gf-value [value]="version" />
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
></gf-value>
</div>
@ -17,8 +23,8 @@
<div class="w-50" i18n>Activity Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount"
></gf-value>
<div *ngIf="transactionCount && userCount">
@ -72,19 +78,6 @@
</div>
</div>
</div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Tags</div>
<div class="w-50">
<table>
<tr *ngFor="let tag of info.tags">
<td class="pl-1">{{ tag.name }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div>
<div class="w-50">

View File

@ -13,13 +13,14 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Platform } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -114,10 +116,13 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((platforms) => {
this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}
@ -130,7 +135,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -170,7 +174,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

View File

@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
) {}
public onCancel() {

View File

@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
@NgModule({
declarations: [CreateOrUpdatePlatformDialog],

View File

@ -2,14 +2,13 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform>
<gf-admin-platform />
</div>
</div>
<!--
<div class="row">
<div class="col">
<h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
</div>
</div>
-->
</div>

View File

@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({
declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
imports: [
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminSettingsModule {}

View File

@ -0,0 +1,85 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,204 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-tag',
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
} else if (params['editTagDialog']) {
if (this.tags) {
const tag = this.tags.find(({ id }) => {
return id === params['tagId'];
});
this.openUpdateTagDialog(tag);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this tag?`
);
if (confirmation) {
this.deleteTag(aId);
}
}
public onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.adminService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
private fetchTags() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}
private openCreateTagDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
if (tag) {
this.adminService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openUpdateTagDialog({ id, name }) {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
id,
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
if (tag) {
this.adminService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AdminTagComponent } from './admin-tag.component';
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
@NgModule({
declarations: [AdminTagComponent],
exports: [AdminTagComponent],
imports: [
CommonModule,
GfCreateOrUpdateTagDialogModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminTagModule {}

View File

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-tag-dialog',
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public onCancel() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTagDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateTagDialogModule {}

View File

@ -0,0 +1,5 @@
import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams {
tag: Tag;
}

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -20,13 +21,15 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public user: User;
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>();
@ -44,6 +47,29 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
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
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -118,7 +144,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.users = users;
this.dataSource = new MatTableDataSource(users);
this.changeDetectorRef.markForCheck();
});

View File

@ -2,107 +2,193 @@
<div class="row">
<div class="col">
<div class="users">
<table class="gf-table">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2 text-right">#</th>
<th class="mat-mdc-header-cell px-1 py-2" i18n>User</th>
<table class="gf-table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index">
<th
*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"
*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; let i=index"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
{{ i + 1 }}
</td>
</ng-container>
<ng-container matColumnDef="user">
<th
*ngIf="hasPermissionForSubscription"
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
i18n
mat-header-cell
>
Last Request
User
</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
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<td class="mat-mdc-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-mdc-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span
>{{ element.id }}</span
>
<span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span
>{{ (element.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"
*ngIf="element?.subscription?.type === 'Premium'"
class="ml-1"
[enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(userItem.subscription.expiresAt) + ' (' + (userItem.subscription.expiresAt | date: defaultDateFormat) + ')'"
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
></gf-premium-indicator>
</div>
</td>
<td
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
matColumnDef="country"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
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]="userItem.country"
>{{ getEmojiFlag(userItem.country) }}</span
<span class="h5" [title]="element.country"
>{{ getEmojiFlag(element.country) }}</span
>
</td>
<td class="mat-mdc-cell px-1 py-2">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="registration">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
>
<ng-container i18n>Registration</ng-container>
</th>
<td
*ngIf="hasPermissionForSubscription"
*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]="userItem.engagement"
[value]="element.engagement"
></gf-value>
</td>
<td
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
class="mat-mdc-cell px-1 py-2"
matColumnDef="lastRequest"
>
{{ formatDistanceToNow(userItem.lastActivity) }}
<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>
<td class="mat-mdc-cell px-1 py-2">
</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
@ -115,23 +201,33 @@
<button
*ngIf="hasPermissionToImpersonateAllUsers"
mat-menu-item
(click)="onImpersonateUser(userItem.id)"
(click)="onImpersonateUser(element.id)"
>
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span>
</button>
<button
mat-menu-item
[disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)"
[disabled]="element.id === user?.id"
(click)="onDeleteUser(element.id)"
>
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span>
</button>
</mat-menu>
</td>
</tr>
</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>
</div>
</div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatMenuModule
MatMenuModule,
MatTableModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -110,6 +110,34 @@
>About</a
>
</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">
<button
class="no-min-width px-1"
@ -272,7 +300,7 @@
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatuers
'text-decoration-underline': currentRoute === routeFeatures
}"
[routerLink]="routerLinkFeatures"
>Features</a

View File

@ -22,7 +22,7 @@
}
.mdc-button {
height: unset;
height: 100%;
&:not(.mat-primary) {
background-color: transparent;

View File

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
Output
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
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';
@ -18,6 +21,7 @@ import {
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./header.component.scss']
})
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() deviceType: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: AssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
permissions.accessAdminControl
);
this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions,
permissions.accessAssistant
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
);
}
public closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu();
}
public impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
this.isMenuOpen = true;
}
public onOpenAssistant() {
this.assistantElement.initialize();
}
public onSignOut() {
this.signOut.next();
}

View File

@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
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 { HeaderComponent } from './header.component';
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
exports: [HeaderComponent],
imports: [
CommonModule,
GfAssistantModule,
GfLogoModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,

View File

@ -81,8 +81,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.update();
}
public onChangeDateRange(dateRange: DateRange) {

View File

@ -1,6 +1,6 @@
<div class="container">
<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="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
@ -8,15 +8,15 @@
<gf-line-chart
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMin="0"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
></gf-line-chart>
<gf-fear-and-greed-index

View File

@ -1,5 +1,5 @@
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioPerformanceModule {}

View File

@ -213,11 +213,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
@ -225,11 +225,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>

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