Compare commits
80 Commits
Author | SHA1 | Date | |
---|---|---|---|
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 |
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- [ ] Cloud
|
- Cloud or Self-hosted
|
||||||
- [ ] Self-hosted
|
|
||||||
|
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 16
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
142
CHANGELOG.md
142
CHANGELOG.md
@ -5,6 +5,147 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.288.0 - 2023-07-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the loading state during filtering on the allocations page
|
||||||
|
- Beautified the names with ampersand (`&`) in the asset profile
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.287.0 - 2023-07-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the average buy price in the position detail chart if there is no holding
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
- Refactored the blog articles to standalone components
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sorting by currency in the activities table
|
||||||
|
|
||||||
|
## 1.286.0 - 2023-07-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of (wealth) items and liabilities
|
||||||
|
|
||||||
|
## 1.285.0 - 2023-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
|
||||||
|
- Added pagination to the historical market data table of the admin control panel
|
||||||
|
- Added the attribute `headers` to the scraper configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.284.0 - 2023-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the currency to the cash balance in the create or update account dialog
|
||||||
|
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
|
||||||
|
|
||||||
|
## 1.283.5 - 2023-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the caching for current market prices
|
||||||
|
- Added a loading indicator to the import dividends dialog
|
||||||
|
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the selected item of the holding selector in the import dividends dialog
|
||||||
|
- Extended the symbol search component by asset sub classes
|
||||||
|
|
||||||
|
## 1.282.0 - 2023-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon to the external links in the footer navigation
|
||||||
|
- Added the ability to add an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the use of permissions on the about page
|
||||||
|
- Harmonized the use of permissions on the landing page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Portuguese (`pt`)
|
||||||
|
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||||
|
|
||||||
|
## 1.281.0 - 2023-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the feature overview page by liabilities
|
||||||
|
- Set up the language localization for Portuguese (`pt`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the symbol search to a dedicated component
|
||||||
|
- Improved the column headers in the holdings table for mobile
|
||||||
|
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||||
|
|
||||||
|
## 1.280.1 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for liabilities
|
||||||
|
|
||||||
|
## 1.279.0 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a note for accounts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the value nullification related to the investment streaks
|
||||||
|
- Fixed an issue in the public page related to the impersonation service
|
||||||
|
|
||||||
|
## 1.278.0 - 2023-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the clone functionality of a transaction by the quantity
|
||||||
|
- Changed the direction of the ellipsis icon in various tables
|
||||||
|
- Extracted the license to a dedicated tab on the about page
|
||||||
|
- Displayed the link to the markets overview in the footer based on a permission
|
||||||
|
- Improved the spacing in the benchmark comparator
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||||
|
|
||||||
|
## 1.277.0 - 2023-06-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the investment streaks to the analysis page
|
||||||
|
- Added support for a unit in the value component
|
||||||
|
- Added a semantic list structure to the header navigation
|
||||||
|
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the date format parsing in the activities import
|
||||||
|
|
||||||
## 1.276.0 - 2023-06-03
|
## 1.276.0 - 2023-06-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -753,7 +894,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added support for the dividend timeline grouped by year
|
- Added support for the dividend timeline grouped by year
|
||||||
- Added support for the investment timeline grouped by year
|
- Added support for the investment timeline grouped by year
|
||||||
- Set up the language localization for Français (`fr`)
|
- Set up the language localization for Français (`fr`)
|
||||||
- Set up the language localization for Português (`pt`)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:16-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class CreateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -26,11 +28,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -245,7 +248,11 @@ export class AdminController {
|
|||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -270,7 +277,13 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.adminService.getMarketData(filters);
|
return this.adminService.getMarketData({
|
||||||
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -328,6 +341,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
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';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
PROPERTY_CURRENCIES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
|
||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ export class AdminService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -35,6 +39,38 @@ export class AdminService {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addAssetProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
@ -65,7 +101,21 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
public async getMarketData({
|
||||||
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip,
|
||||||
|
take = DEFAULT_PAGE_SIZE
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminMarketData> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||||
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
@ -75,42 +125,33 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
|
||||||
|
|
||||||
if (filtersByAssetSubClass) {
|
if (filtersByAssetSubClass) {
|
||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
} else {
|
|
||||||
currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
assetClass: 'CASH',
|
|
||||||
countriesCount: 0,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
if (sortColumn) {
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
|
if (sortColumn === 'activitiesCount') {
|
||||||
|
orderBy = {
|
||||||
|
Order: {
|
||||||
|
_count: sortDirection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
@ -129,38 +170,48 @@ export class AdminService {
|
|||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
).map((symbolProfile) => {
|
this.prismaService.symbolProfile.count({ where })
|
||||||
const countriesCount = symbolProfile.countries
|
]);
|
||||||
? Object.keys(symbolProfile.countries).length
|
|
||||||
: 0;
|
return {
|
||||||
|
count,
|
||||||
|
marketData: assetProfiles.map(
|
||||||
|
({
|
||||||
|
_count,
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countries,
|
||||||
|
dataSource,
|
||||||
|
Order,
|
||||||
|
sectors,
|
||||||
|
symbol
|
||||||
|
}) => {
|
||||||
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
marketDataItem.dataSource === dataSource &&
|
||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
const sectorsCount = symbolProfile.sectors
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
? Object.keys(symbolProfile.sectors).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
activitiesCount: symbolProfile._count.Order,
|
activitiesCount: _count.Order,
|
||||||
assetClass: symbolProfile.assetClass,
|
date: Order?.[0]?.date
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
|
||||||
comment: symbolProfile.comment,
|
|
||||||
dataSource: symbolProfile.dataSource,
|
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
|
||||||
symbol: symbolProfile.symbol
|
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
)
|
||||||
return {
|
|
||||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,12 +249,14 @@ export class AdminService {
|
|||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
|
@ -21,6 +21,7 @@ export class ExportService {
|
|||||||
select: {
|
select: {
|
||||||
accountType: true,
|
accountType: true,
|
||||||
balance: true,
|
balance: true,
|
||||||
|
comment: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
id: true,
|
id: true,
|
||||||
isExcluded: true,
|
isExcluded: true,
|
||||||
|
@ -104,6 +104,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||||
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230701.jpg';
|
||||||
|
title = `Exploring the Path to FIRE - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -202,7 +202,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
activity.dataSource = DataSource.MANUAL;
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource =
|
activity.dataSource =
|
||||||
|
@ -96,7 +96,7 @@ export class OrderService {
|
|||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
@ -129,7 +129,10 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft =
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
@ -201,7 +204,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,7 +323,11 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
@ -368,7 +375,7 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
|
|||||||
[],
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
propertyService
|
propertyService,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
|
@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
expect(investmentsByMonth).toEqual([
|
||||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||||
|
{ date: '2015-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-11-01', investment: new Big('0') },
|
||||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -544,7 +544,7 @@ export class PortfolioCalculator {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments: { date: string; investment: Big }[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByGroup = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
@ -554,13 +554,11 @@ export class PortfolioCalculator {
|
|||||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same group: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByGroup = investmentByGroup.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New group: Store previous group and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(
|
date: format(
|
||||||
@ -595,7 +593,39 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return investments;
|
// Fill in the missing dates with investment = 0
|
||||||
|
const startDate = parseDate(first(this.orders).date);
|
||||||
|
const endDate = parseDate(last(this.orders).date);
|
||||||
|
|
||||||
|
const allDates: string[] = [];
|
||||||
|
currentDate = startDate;
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
allDates.push(
|
||||||
|
format(
|
||||||
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of allDates) {
|
||||||
|
const existingInvestment = investments.find((investment) => {
|
||||||
|
return investment.date === date;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingInvestment) {
|
||||||
|
investments.push({ date, investment: new Big(0) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
|
@ -162,6 +162,7 @@ export class PortfolioController {
|
|||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
@ -258,11 +259,12 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments({
|
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -278,6 +280,11 @@ export class PortfolioController {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment / maxInvestment
|
investment: item.investment / maxInvestment
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -287,9 +294,14 @@ export class PortfolioController {
|
|||||||
investments = investments.map((item) => {
|
investments = investments.map((item) => {
|
||||||
return nullifyValuesInObject(item, ['investment']);
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { investments };
|
return { investments, streaks };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
|
@ -28,6 +28,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
@ -252,13 +253,15 @@ export class PortfolioService {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate
|
||||||
}: {
|
}: {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<InvestmentItem[]> {
|
savingsRate: number;
|
||||||
|
}): Promise<PortfolioInvestments> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -276,7 +279,10 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
investments: [],
|
||||||
|
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
@ -346,9 +352,23 @@ export class PortfolioService {
|
|||||||
parseDate(investments[0]?.date)
|
parseDate(investments[0]?.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
return investments.filter(({ date }) => {
|
investments = investments.filter(({ date }) => {
|
||||||
return !isBefore(parseDate(date), startDate);
|
return !isBefore(parseDate(date), startDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let streaks: PortfolioInvestments['streaks'];
|
||||||
|
|
||||||
|
if (savingsRate) {
|
||||||
|
streaks = this.getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
investments,
|
||||||
|
streaks
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart({
|
public async getChart({
|
||||||
@ -1282,12 +1302,11 @@ export class PortfolioService {
|
|||||||
}: {
|
}: {
|
||||||
activities: OrderWithAccount[];
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date and type dividend
|
// Filter out all activities before given date (drafts) and type dividend
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(activity.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
activity.type === TypeOfOrder.DIVIDEND
|
activity.type === TypeOfOrder.DIVIDEND
|
||||||
@ -1411,7 +1430,7 @@ export class PortfolioService {
|
|||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date
|
// Filter out all activities before given date (drafts)
|
||||||
return isBefore(date, new Date(activity.date));
|
return isBefore(date, new Date(activity.date));
|
||||||
})
|
})
|
||||||
.map(({ fee, SymbolProfile }) => {
|
.map(({ fee, SymbolProfile }) => {
|
||||||
@ -1458,19 +1477,37 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date and type item
|
// Filter out all activities before given date (drafts) and type item
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(order.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
order.type === TypeOfOrder.ITEM
|
activity.type === TypeOfOrder.ITEM
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
|
this.request.user.Settings.settings.baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLiabilities(activities: OrderWithAccount[]) {
|
||||||
|
return activities
|
||||||
|
.filter(({ type }) => {
|
||||||
|
return type === TypeOfOrder.LIABILITY;
|
||||||
|
})
|
||||||
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
|
SymbolProfile.currency,
|
||||||
this.request.user.Settings.settings.baseCurrency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1510,6 +1547,28 @@ export class PortfolioService {
|
|||||||
return portfolioStart;
|
return portfolioStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate
|
||||||
|
}: {
|
||||||
|
investments: InvestmentItem[];
|
||||||
|
savingsRate: number;
|
||||||
|
}) {
|
||||||
|
let currentStreak = 0;
|
||||||
|
let longestStreak = 0;
|
||||||
|
|
||||||
|
for (const { investment } of investments) {
|
||||||
|
if (investment >= savingsRate) {
|
||||||
|
currentStreak++;
|
||||||
|
longestStreak = Math.max(longestStreak, currentStreak);
|
||||||
|
} else {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentStreak, longestStreak };
|
||||||
|
}
|
||||||
|
|
||||||
private async getSummary({
|
private async getSummary({
|
||||||
balanceInBaseCurrency,
|
balanceInBaseCurrency,
|
||||||
emergencyFundPositionsValueInBaseCurrency,
|
emergencyFundPositionsValueInBaseCurrency,
|
||||||
@ -1559,6 +1618,7 @@ export class PortfolioService {
|
|||||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = activities[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
const items = this.getItems(activities).toNumber();
|
const items = this.getItems(activities).toNumber();
|
||||||
|
const liabilities = this.getLiabilities(activities).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||||
@ -1591,6 +1651,7 @@ export class PortfolioService {
|
|||||||
.plus(performanceInformation.performance.currentValue)
|
.plus(performanceInformation.performance.currentValue)
|
||||||
.plus(items)
|
.plus(items)
|
||||||
.plus(excludedAccountsAndActivities)
|
.plus(excludedAccountsAndActivities)
|
||||||
|
.minus(liabilities)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
@ -1617,6 +1678,7 @@ export class PortfolioService {
|
|||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
items,
|
items,
|
||||||
|
liabilities,
|
||||||
netWorth,
|
netWorth,
|
||||||
totalBuy,
|
totalBuy,
|
||||||
totalSell,
|
totalSell,
|
||||||
@ -1841,13 +1903,6 @@ export class PortfolioService {
|
|||||||
return { accounts, platforms };
|
return { accounts, platforms };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
|
||||||
const impersonationUserId =
|
|
||||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
|
||||||
|
|
||||||
return impersonationUserId || aUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
orders: OrderWithAccount[],
|
orders: OrderWithAccount[],
|
||||||
currency: string,
|
currency: string,
|
||||||
@ -1874,4 +1929,11 @@ export class PortfolioService {
|
|||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||||
|
|
||||||
|
return impersonationUserId || aUserId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
@ -13,6 +14,10 @@ export class RedisCacheService {
|
|||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return `quote-${dataSource}-${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
}
|
}
|
||||||
|
@ -36,10 +36,12 @@ export class SymbolController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
|
@Query('query') query = ''
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup({
|
return this.symbolService.lookup({
|
||||||
|
includeIndices,
|
||||||
query: query.toLowerCase(),
|
query: query.toLowerCase(),
|
||||||
user: this.request.user
|
user: this.request.user
|
||||||
});
|
});
|
||||||
@ -60,7 +62,7 @@ export class SymbolController {
|
|||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||||
): Promise<SymbolItem> {
|
): Promise<SymbolItem> {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -81,9 +81,11 @@ export class SymbolService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async lookup({
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
}: {
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
user: UserWithSettings;
|
user: UserWithSettings;
|
||||||
}): Promise<{ items: LookupItem[] }> {
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
@ -95,6 +97,7 @@ export class SymbolService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search({
|
const { items } = await this.dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
});
|
});
|
||||||
|
@ -166,7 +166,7 @@ export class UserService {
|
|||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Analytics?.activityCount % 20 === 0 &&
|
Analytics?.activityCount % 10 === 0 &&
|
||||||
user.subscription?.type === 'Basic'
|
user.subscription?.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
|||||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||||
const object = cloneDeep(aObject);
|
const object = cloneDeep(aObject);
|
||||||
|
|
||||||
|
if (object) {
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
object[key] = null;
|
object[key] = null;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as bodyParser from 'body-parser';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
@ -10,11 +12,12 @@ async function bootstrap() {
|
|||||||
const configApp = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
const configService = configApp.get<ConfigService>(ConfigService);
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
logger: environment.production
|
logger: environment.production
|
||||||
? ['error', 'log', 'warn']
|
? ['error', 'log', 'warn']
|
||||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
});
|
});
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
defaultVersion: '1',
|
defaultVersion: '1',
|
||||||
@ -32,6 +35,22 @@ async function bootstrap() {
|
|||||||
// Support 10mb csv/json files for importing activities
|
// Support 10mb csv/json files for importing activities
|
||||||
app.use(bodyParser.json({ limit: '10mb' }));
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||||
|
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
||||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const PORT = configService.get<number>('PORT') || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
|
@ -16,6 +16,7 @@ export class ConfigurationService {
|
|||||||
default: 'USD'
|
default: 'USD'
|
||||||
}),
|
}),
|
||||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||||
|
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
||||||
|
@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
const result = await this.alphaVantage.data.search(aQuery);
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
|
const result = await this.alphaVantage.data.search(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result?.bestMatches?.map((bestMatch) => {
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
|
@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
return 'bitcoin';
|
return 'bitcoin';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items: LookupItem[] = [];
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
|
||||||
`${this.URL}/search?query=${aQuery}`,
|
|
||||||
'GET',
|
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const { coins } = await get();
|
const { coins } = await get();
|
||||||
|
|
||||||
items = coins.map(({ id: symbol, name }) => {
|
items = coins.map(({ id: symbol, name }) => {
|
||||||
|
@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
let name = longName;
|
let name = longName;
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
|
name = name.replace('&', '&');
|
||||||
|
|
||||||
name = name.replace('Amundi Index Solutions - ', '');
|
name = name.replace('Amundi Index Solutions - ', '');
|
||||||
name = name.replace('iShares ETF (CH) - ', '');
|
name = name.replace('iShares ETF (CH) - ', '');
|
||||||
name = name.replace('iShares III Public Limited Company - ', '');
|
name = name.replace('iShares III Public Limited Company - ', '');
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
@ -27,7 +28,8 @@ export class DataProviderService {
|
|||||||
private readonly dataProviderInterfaces: DataProviderInterface[],
|
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {
|
) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
@ -235,9 +237,43 @@ export class DataProviderService {
|
|||||||
} = {};
|
} = {};
|
||||||
const startTimeTotal = performance.now();
|
const startTimeTotal = performance.now();
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
// Get items from cache
|
||||||
|
const itemsToFetch: IDataGatheringItem[] = [];
|
||||||
|
|
||||||
const promises = [];
|
for (const { dataSource, symbol } of items) {
|
||||||
|
const quoteString = await this.redisCacheService.get(
|
||||||
|
this.redisCacheService.getQuoteKey({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (quoteString) {
|
||||||
|
try {
|
||||||
|
const cachedDataProviderResponse = JSON.parse(quoteString);
|
||||||
|
response[symbol] = cachedDataProviderResponse;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quoteString) {
|
||||||
|
itemsToFetch.push({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfItemsInCache = Object.keys(response)?.length;
|
||||||
|
|
||||||
|
if (numberOfItemsInCache) {
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${numberOfItemsInCache} quote${
|
||||||
|
numberOfItemsInCache > 1 ? 's' : ''
|
||||||
|
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||||
|
3
|
||||||
|
)} seconds`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
itemsGroupedByDataSource
|
itemsGroupedByDataSource
|
||||||
@ -271,6 +307,15 @@ export class DataProviderService {
|
|||||||
result
|
result
|
||||||
)) {
|
)) {
|
||||||
response[symbol] = dataProviderResponse;
|
response[symbol] = dataProviderResponse;
|
||||||
|
|
||||||
|
this.redisCacheService.set(
|
||||||
|
this.redisCacheService.getQuoteKey({
|
||||||
|
dataSource: DataSource[dataSource],
|
||||||
|
symbol
|
||||||
|
}),
|
||||||
|
JSON.stringify(dataProviderResponse),
|
||||||
|
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
@ -283,7 +328,7 @@ export class DataProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.marketDataService.updateMany({
|
this.marketDataService.updateMany({
|
||||||
data: Object.keys(response)
|
data: Object.keys(response)
|
||||||
.filter((symbol) => {
|
.filter((symbol) => {
|
||||||
return (
|
return (
|
||||||
@ -322,9 +367,11 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search({
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
}: {
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
user: UserWithSettings;
|
user: UserWithSettings;
|
||||||
}): Promise<{ items: LookupItem[] }> {
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
@ -347,7 +394,12 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dataSource of dataSources) {
|
for (const dataSource of dataSources) {
|
||||||
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
promises.push(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = await Promise.all(promises);
|
const searchResults = await Promise.all(promises);
|
||||||
|
@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return !symbol.endsWith('.FOREX');
|
return !symbol.endsWith('.FOREX');
|
||||||
})
|
})
|
||||||
.map((symbol) => {
|
.map((symbol) => {
|
||||||
return this.search(symbol);
|
return this.search({ query: symbol });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return 'AAPL.US';
|
return 'AAPL.US';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
const searchResult = await this.getSearchResult(aQuery);
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
|
const searchResult = await this.getSearchResult(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: searchResult
|
items: searchResult
|
||||||
|
@ -143,12 +143,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
return 'AAPL';
|
return 'AAPL';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items: LookupItem[] = [];
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
|
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'json',
|
||||||
200
|
200
|
||||||
|
@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
return 'INDEXSP:.INX';
|
return 'INDEXSP:.INX';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const items = await this.prismaService.symbolProfile.findMany({
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
select: {
|
select: {
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: {
|
name: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
symbol: {
|
symbol: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -42,5 +42,11 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getTestSymbol(): string;
|
getTestSymbol(): string;
|
||||||
|
|
||||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
|
|||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
[{ symbol, dataSource: this.getName() }]
|
[{ symbol, dataSource: this.getName() }]
|
||||||
);
|
);
|
||||||
const { defaultMarketPrice, selector, url } =
|
const {
|
||||||
symbolProfile.scraperConfiguration ?? {};
|
defaultMarketPrice,
|
||||||
|
headers = {},
|
||||||
|
selector,
|
||||||
|
url
|
||||||
|
} = symbolProfile.scraperConfiguration ?? {};
|
||||||
|
|
||||||
if (defaultMarketPrice) {
|
if (defaultMarketPrice) {
|
||||||
const historical: {
|
const historical: {
|
||||||
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = bent(url, 'GET', 'string', 200, {});
|
const get = bent(url, 'GET', 'string', 200, headers);
|
||||||
|
|
||||||
const html = await get();
|
const html = await get();
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items = await this.prismaService.symbolProfile.findMany({
|
let items = await this.prismaService.symbolProfile.findMany({
|
||||||
select: {
|
select: {
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
|
|||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: {
|
name: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
symbol: {
|
symbol: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return 'AAPL';
|
return 'AAPL';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const items: LookupItem[] = [];
|
const items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const searchResult = await yahooFinance.search(aQuery);
|
const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
|
||||||
|
|
||||||
|
if (includeIndices) {
|
||||||
|
quoteTypes.push('INDEX');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult = await yahooFinance.search(query);
|
||||||
|
|
||||||
const quotes = searchResult.quotes
|
const quotes = searchResult.quotes
|
||||||
.filter((quote) => {
|
.filter((quote) => {
|
||||||
@ -295,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
)
|
)
|
||||||
)) ||
|
)) ||
|
||||||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
|
quoteTypes.includes(quoteType)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
|
@ -12,6 +12,7 @@ export class ImpersonationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async validateImpersonationId(aId = '') {
|
public async validateImpersonationId(aId = '') {
|
||||||
|
if (this.request.user) {
|
||||||
const accessObject = await this.prismaService.access.findFirst({
|
const accessObject = await this.prismaService.access.findFirst({
|
||||||
where: {
|
where: {
|
||||||
GranteeUser: { id: this.request.user.id },
|
GranteeUser: { id: this.request.user.id },
|
||||||
@ -20,7 +21,7 @@ export class ImpersonationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (accessObject?.userId) {
|
if (accessObject?.userId) {
|
||||||
return accessObject?.userId;
|
return accessObject.userId;
|
||||||
} else if (
|
} else if (
|
||||||
hasPermission(
|
hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -29,6 +30,19 @@ export class ImpersonationService {
|
|||||||
) {
|
) {
|
||||||
return aId;
|
return aId;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Public access
|
||||||
|
const accessObject = await this.prismaService.access.findFirst({
|
||||||
|
where: {
|
||||||
|
GranteeUser: null,
|
||||||
|
User: { id: aId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accessObject?.userId) {
|
||||||
|
return accessObject.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
BASE_CURRENCY: string;
|
BASE_CURRENCY: string;
|
||||||
BETTER_UPTIME_API_KEY: string;
|
BETTER_UPTIME_API_KEY: string;
|
||||||
|
CACHE_QUOTES_TTL: number;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||||
DATA_SOURCE_IMPORT: string;
|
DATA_SOURCE_IMPORT: string;
|
||||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
|||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async add(
|
||||||
|
assetProfile: Prisma.SymbolProfileCreateInput
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||||
|
}
|
||||||
|
|
||||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
@ -90,11 +96,12 @@ export class SymbolProfileService {
|
|||||||
public updateSymbolProfile({
|
public updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.update({
|
return this.prismaService.symbolProfile.update({
|
||||||
data: { comment, symbolMapping },
|
data: { comment, scraperConfiguration, symbolMapping },
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -189,6 +196,8 @@ export class SymbolProfileService {
|
|||||||
if (scraperConfiguration) {
|
if (scraperConfiguration) {
|
||||||
return {
|
return {
|
||||||
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
||||||
|
headers:
|
||||||
|
scraperConfiguration.headers as ScraperConfiguration['headers'],
|
||||||
selector: scraperConfiguration.selector as string,
|
selector: scraperConfiguration.selector as string,
|
||||||
url: scraperConfiguration.url as string
|
url: scraperConfiguration.url as string
|
||||||
};
|
};
|
||||||
|
@ -18,36 +18,6 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||||
})),
|
})),
|
||||||
...[
|
|
||||||
'about/changelog',
|
|
||||||
/////
|
|
||||||
'a-propos/changelog',
|
|
||||||
'informazioni-su/changelog',
|
|
||||||
'over/changelog',
|
|
||||||
'sobre/changelog',
|
|
||||||
'ueber-uns/changelog'
|
|
||||||
].map((path) => ({
|
|
||||||
path,
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/about/changelog/changelog-page.module').then(
|
|
||||||
(m) => m.ChangelogPageModule
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
...[
|
|
||||||
'about/privacy-policy',
|
|
||||||
/////
|
|
||||||
'a-propos/politique-de-confidentialite',
|
|
||||||
'informazioni-su/informativa-sulla-privacy',
|
|
||||||
'over/privacybeleid',
|
|
||||||
'sobre/politica-de-privacidad',
|
|
||||||
'ueber-uns/datenschutzbestimmungen'
|
|
||||||
].map((path) => ({
|
|
||||||
path,
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
|
||||||
(m) => m.PrivacyPolicyPageModule
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -77,97 +47,6 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
path: 'blog/2021/07/hallo-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
|
||||||
).then((m) => m.HalloGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2021/07/hello-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
|
||||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
|
|
||||||
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
|
|
||||||
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/08/500-stars-on-github',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
|
||||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/10/hacktoberfest-2022',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
|
||||||
).then((m) => m.Hacktoberfest2022PageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/11/black-friday-2022',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
|
||||||
).then((m) => m.BlackFriday2022PageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
|
||||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
|
||||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
|
||||||
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
|
|
||||||
).then((m) => m.ThousandStarsOnGitHubPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
|
|
||||||
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -179,6 +58,7 @@ const routes: Routes = [
|
|||||||
'domande-piu-frequenti',
|
'domande-piu-frequenti',
|
||||||
'foire-aux-questions',
|
'foire-aux-questions',
|
||||||
'haeufig-gestellte-fragen',
|
'haeufig-gestellte-fragen',
|
||||||
|
'perguntas-mais-frequentes',
|
||||||
'preguntas-mas-frecuentes',
|
'preguntas-mas-frecuentes',
|
||||||
'vaak-gestelde-vragen'
|
'vaak-gestelde-vragen'
|
||||||
].map((path) => ({
|
].map((path) => ({
|
||||||
@ -243,6 +123,7 @@ const routes: Routes = [
|
|||||||
'pricing',
|
'pricing',
|
||||||
/////
|
/////
|
||||||
'precios',
|
'precios',
|
||||||
|
'precos',
|
||||||
'preise',
|
'preise',
|
||||||
'prezzi',
|
'prezzi',
|
||||||
'prijzen',
|
'prijzen',
|
||||||
@ -259,6 +140,7 @@ const routes: Routes = [
|
|||||||
/////
|
/////
|
||||||
'enregistrement',
|
'enregistrement',
|
||||||
'iscrizione',
|
'iscrizione',
|
||||||
|
'registo',
|
||||||
'registratie',
|
'registratie',
|
||||||
'registrierung',
|
'registrierung',
|
||||||
'registro'
|
'registro'
|
||||||
|
@ -66,7 +66,9 @@
|
|||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="h6 mt-2" i18n>Personal Finance</div>
|
<div class="h6 mt-2" i18n>Personal Finance</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li><a i18n [routerLink]="['/markets']">Markets</a></li>
|
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
|
||||||
|
<a i18n [routerLink]="['/markets']">Markets</a>
|
||||||
|
</li>
|
||||||
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
|
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -78,15 +80,16 @@
|
|||||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a i18n [routerLink]="['/about', 'changelog']"
|
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
|
||||||
>Changelog & License</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li><a i18n [routerLink]="['/features']">Features</a></li>
|
<li><a i18n [routerLink]="['/features']">Features</a></li>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<li *ngIf="hasPermissionForSubscription">
|
||||||
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
|
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
|
||||||
</li>
|
</li>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<li>
|
||||||
|
<a i18n [routerLink]="['/about', 'license']">License</a>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="hasPermissionForStatistics">
|
||||||
<a [routerLink]="['/open']">Open Startup</a>
|
<a [routerLink]="['/open']">Open Startup</a>
|
||||||
</li>
|
</li>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<li *ngIf="hasPermissionForSubscription">
|
||||||
@ -98,9 +101,13 @@
|
|||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<li *ngIf="hasPermissionForSubscription">
|
||||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
<a
|
||||||
>Status</a
|
class="align-items-baseline d-flex"
|
||||||
>
|
href="https://status.ghostfol.io"
|
||||||
|
target="_blank"
|
||||||
|
title="Ghostfolio Status"
|
||||||
|
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
|
></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -109,24 +116,30 @@
|
|||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
target="_blank"
|
||||||
title="Find Ghostfolio on GitHub"
|
title="Find Ghostfolio on GitHub"
|
||||||
>GitHub</a
|
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
title="Join the Ghostfolio Slack channel"
|
target="_blank"
|
||||||
>Slack</a
|
title="Join the Ghostfolio Slack community"
|
||||||
>
|
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
|
target="_blank"
|
||||||
title="Follow Ghostfolio on Twitter"
|
title="Follow Ghostfolio on Twitter"
|
||||||
>Twitter</a
|
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li> </li>
|
<li> </li>
|
||||||
<li>
|
<li>
|
||||||
@ -147,6 +160,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +33,9 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionForBlog: boolean;
|
public hasPermissionForBlog: boolean;
|
||||||
|
public hasPermissionForStatistics: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public pageTitle: string;
|
public pageTitle: string;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -69,6 +71,16 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionForStatistics = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.enableStatistics
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.enableFearAndGreedIndex
|
||||||
|
);
|
||||||
|
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
|
@ -47,7 +47,7 @@
|
|||||||
[matMenuTriggerFor]="transactionMenu"
|
[matMenuTriggerFor]="transactionMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
@ -57,6 +57,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -12,8 +12,9 @@
|
|||||||
<div class="col-12 d-flex justify-content-center mb-3">
|
<div class="col-12 d-flex justify-content-center mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
size="large"
|
size="large"
|
||||||
[currency]="user?.settings?.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[unit]="user?.settings?.baseCurrency"
|
||||||
[value]="valueInBaseCurrency"
|
[value]="valueInBaseCurrency"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -24,8 +25,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="currency"
|
[isCurrency]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[unit]="currency"
|
||||||
[value]="balance"
|
[value]="balance"
|
||||||
>Cash Balance</gf-value
|
>Cash Balance</gf-value
|
||||||
>
|
>
|
||||||
@ -34,8 +36,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="currency"
|
[isCurrency]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[unit]="currency"
|
||||||
[value]="equity"
|
[value]="equity"
|
||||||
>Equity</gf-value
|
>Equity</gf-value
|
||||||
>
|
>
|
||||||
|
@ -207,6 +207,30 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="comment">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
></th>
|
||||||
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
|
<button
|
||||||
|
*ngIf="element.comment"
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
title="Note"
|
||||||
|
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="document-text-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
@ -216,7 +240,7 @@
|
|||||||
[matMenuTriggerFor]="accountMenu"
|
[matMenuTriggerFor]="accountMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||||
|
@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
'balance',
|
'balance',
|
||||||
'value',
|
'value',
|
||||||
'currency',
|
'currency',
|
||||||
'valueInBaseCurrency'
|
'valueInBaseCurrency',
|
||||||
|
'comment'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.showActions) {
|
if (this.showActions) {
|
||||||
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenComment(aComment: string) {
|
||||||
|
alert(aComment);
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.accountToUpdate.emit(aAccount);
|
this.accountToUpdate.emit(aAccount);
|
||||||
}
|
}
|
||||||
|
@ -108,7 +108,7 @@
|
|||||||
[matMenuTriggerFor]="jobActionsMenu"
|
[matMenuTriggerFor]="jobActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onViewData(job.data)">
|
<button mat-menu-item (click)="onViewData(job.data)">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
@ -7,23 +8,26 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatSort } from '@angular/material/sort';
|
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSort, Sort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||||
|
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
|
|||||||
styleUrls: ['./admin-market-data.scss'],
|
styleUrls: ['./admin-market-data.scss'],
|
||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent
|
||||||
|
implements AfterViewInit, OnDestroy, OnInit
|
||||||
|
{
|
||||||
|
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
public activeFilters: Filter[] = [];
|
public activeFilters: Filter[] = [];
|
||||||
@ -73,6 +80,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
public filters$ = new Subject<Filter[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
public placeholder = '';
|
public placeholder = '';
|
||||||
|
public pageSize = DEFAULT_PAGE_SIZE;
|
||||||
|
public totalItems = 0;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -80,7 +89,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -99,6 +107,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
dataSource: params['dataSource'],
|
dataSource: params['dataSource'],
|
||||||
symbol: params['symbol']
|
symbol: params['symbol']
|
||||||
});
|
});
|
||||||
|
} else if (params['createAssetProfileDialog']) {
|
||||||
|
this.openCreateAssetProfileDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,33 +123,39 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((filters) => {
|
||||||
|
this.activeFilters = filters;
|
||||||
|
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngAfterViewInit() {
|
||||||
|
this.sort.sortChange.subscribe(
|
||||||
|
({ active: sortColumn, direction }: Sort) => {
|
||||||
|
this.paginator.pageIndex = 0;
|
||||||
|
|
||||||
|
this.loadData({
|
||||||
|
sortColumn,
|
||||||
|
sortDirection: <Prisma.SortOrder>direction,
|
||||||
|
pageIndex: this.paginator.pageIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
this.filters$
|
public onChangePage(page: PageEvent) {
|
||||||
.pipe(
|
this.loadData({
|
||||||
distinctUntilChanged(),
|
pageIndex: page.pageIndex,
|
||||||
switchMap((filters) => {
|
sortColumn: this.sort.active,
|
||||||
this.isLoading = true;
|
sortDirection: <Prisma.SortOrder>this.sort.direction
|
||||||
this.activeFilters = filters;
|
|
||||||
this.placeholder =
|
|
||||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
|
||||||
|
|
||||||
return this.dataService.fetchAdminMarketData({
|
|
||||||
filters: this.activeFilters
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
takeUntil(this.unsubscribeSubject)
|
|
||||||
)
|
|
||||||
.subscribe(({ marketData }) => {
|
|
||||||
this.dataSource = new MatTableDataSource(marketData);
|
|
||||||
this.dataSource.sort = this.sort;
|
|
||||||
|
|
||||||
this.isLoading = false;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,6 +224,47 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadData(
|
||||||
|
{
|
||||||
|
pageIndex,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection
|
||||||
|
}: {
|
||||||
|
pageIndex: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
} = { pageIndex: 0 }
|
||||||
|
) {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
if (pageIndex === 0 && this.paginator) {
|
||||||
|
this.paginator.pageIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketData({
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
filters: this.activeFilters,
|
||||||
|
skip: pageIndex * this.pageSize,
|
||||||
|
take: this.pageSize
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ count, marketData }) => {
|
||||||
|
this.totalItems = count;
|
||||||
|
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private openAssetProfileDialog({
|
private openAssetProfileDialog({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -241,4 +298,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openCreateAssetProfileDialog() {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <CreateAssetProfileDialogParams>{
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ dataSource, symbol }) => {
|
||||||
|
if (dataSource && symbol) {
|
||||||
|
this.adminService
|
||||||
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
return this.adminService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters,
|
||||||
|
take: this.pageSize
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="date">
|
<ng-container matColumnDef="date">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>First Activity</ng-container>
|
<ng-container i18n>First Activity</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
@ -74,7 +74,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="marketDataItemCount">
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Historical Data</ng-container>
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -83,7 +83,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="sectorsCount">
|
<ng-container matColumnDef="sectorsCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Sectors Count</ng-container>
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -92,7 +92,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="countriesCount">
|
<ng-container matColumnDef="countriesCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Countries Count</ng-container>
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -140,7 +140,7 @@
|
|||||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
@ -162,6 +162,40 @@
|
|||||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalItems"
|
||||||
|
[ngClass]="{
|
||||||
|
'd-none':
|
||||||
|
(isLoading && totalItems === 0) ||
|
||||||
|
totalItems <= pageSize
|
||||||
|
}"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[showFirstLastButtons]="true"
|
||||||
|
(page)="onChangePage($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
|
||||||
|
<ngx-skeleton-loader
|
||||||
|
*ngIf="isLoading && totalItems === 0"
|
||||||
|
animation="pulse"
|
||||||
|
class="px-4 py-3"
|
||||||
|
[theme]="{
|
||||||
|
height: '1.5rem',
|
||||||
|
width: '100%'
|
||||||
|
}"
|
||||||
|
></ngx-skeleton-loader>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fab-container">
|
||||||
|
<a
|
||||||
|
class="align-items-center d-flex justify-content-center"
|
||||||
|
color="primary"
|
||||||
|
mat-fab
|
||||||
|
[queryParams]="{ createAssetProfileDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||||
|
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataComponent],
|
declarations: [AdminMarketDataComponent],
|
||||||
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
GfActivitiesFilterModule,
|
GfActivitiesFilterModule,
|
||||||
GfAssetProfileDialogModule,
|
GfAssetProfileDialogModule,
|
||||||
|
GfCreateAssetProfileDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
|
MatPaginatorModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
MatTableModule
|
MatTableModule,
|
||||||
|
NgxSkeletonLoaderModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -2,4 +2,11 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
bottom: 2rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
ScraperConfiguration,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||||
public assetProfileForm = this.formBuilder.group({
|
public assetProfileForm = this.formBuilder.group({
|
||||||
comment: '',
|
comment: '',
|
||||||
|
scraperConfiguration: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
});
|
});
|
||||||
public assetSubClass: string;
|
public assetSubClass: string;
|
||||||
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.assetProfileForm.setValue({
|
this.assetProfileForm.setValue({
|
||||||
comment: this.assetProfile?.comment ?? '',
|
comment: this.assetProfile?.comment ?? '',
|
||||||
|
scraperConfiguration: JSON.stringify(
|
||||||
|
this.assetProfile?.scraperConfiguration ?? {}
|
||||||
|
),
|
||||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSubmit() {
|
public onSubmit() {
|
||||||
|
let scraperConfiguration = {};
|
||||||
let symbolMapping = {};
|
let symbolMapping = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
scraperConfiguration = JSON.parse(
|
||||||
|
this.assetProfileForm.controls['scraperConfiguration'].value
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
symbolMapping = JSON.parse(
|
symbolMapping = JSON.parse(
|
||||||
this.assetProfileForm.controls['symbolMapping'].value
|
this.assetProfileForm.controls['symbolMapping'].value
|
||||||
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const assetProfileData: UpdateAssetProfileDto = {
|
const assetProfileData: UpdateAssetProfileDto = {
|
||||||
|
scraperConfiguration,
|
||||||
symbolMapping,
|
symbolMapping,
|
||||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||||
};
|
};
|
||||||
|
@ -162,6 +162,17 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Scraper Configuration</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkTextareaAutosize
|
||||||
|
formControlName="scraperConfiguration"
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Note</mat-label>
|
<mat-label i18n>Note</mat-label>
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
Validators
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-asset-profile-dialog',
|
||||||
|
templateUrl: 'create-asset-profile-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||||
|
public createAssetProfileForm: FormGroup;
|
||||||
|
|
||||||
|
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({
|
||||||
|
searchSymbol: new FormControl(null, [Validators.required])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
this.dialogRef.close({
|
||||||
|
dataSource:
|
||||||
|
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||||
|
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="createAssetProfileForm"
|
||||||
|
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||||
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
|
<gf-symbol-autocomplete
|
||||||
|
formControlName="searchSymbol"
|
||||||
|
[includeIndices]="true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div 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"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Save</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,24 @@
|
|||||||
|
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 { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateAssetProfileDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
GfSymbolAutocompleteModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfCreateAssetProfileDialogModule {}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface CreateAssetProfileDialogParams {
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminData() {
|
private fetchAdminData() {
|
||||||
this.dataService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||||
|
@ -82,7 +82,7 @@
|
|||||||
[matMenuTriggerFor]="platformMenu"
|
[matMenuTriggerFor]="platformMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -30,6 +31,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminData() {
|
private fetchAdminData() {
|
||||||
this.dataService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ users }) => {
|
.subscribe(({ users }) => {
|
||||||
|
@ -109,7 +109,7 @@
|
|||||||
[matMenuTriggerFor]="userMenu"
|
[matMenuTriggerFor]="userMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
<div class="col-md-6 col-xs-12 d-flex">
|
<div class="col-md-6 col-xs-12 d-flex">
|
||||||
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
<div
|
||||||
|
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
|
||||||
|
>
|
||||||
<span i18n>Performance</span>
|
<span i18n>Performance</span>
|
||||||
<gf-premium-indicator
|
<gf-premium-indicator
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
|
@ -8,18 +8,23 @@
|
|||||||
<gf-logo [label]="pageTitle"></gf-logo>
|
<gf-logo [label]="pageTitle"></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<ul class="alig-items-center d-flex list-inline m-0">
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
'font-weight-bold':
|
||||||
|
currentRoute === 'home' || currentRoute === 'zen',
|
||||||
'text-decoration-underline':
|
'text-decoration-underline':
|
||||||
currentRoute === 'home' || currentRoute === 'zen'
|
currentRoute === 'home' || currentRoute === 'zen'
|
||||||
}"
|
}"
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>Overview</a
|
>Overview</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -31,6 +36,8 @@
|
|||||||
[routerLink]="['/portfolio']"
|
[routerLink]="['/portfolio']"
|
||||||
>Portfolio</a
|
>Portfolio</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -42,8 +49,9 @@
|
|||||||
[routerLink]="['/accounts']"
|
[routerLink]="['/accounts']"
|
||||||
>Accounts</a
|
>Accounts</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionToAccessAdminControl"
|
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -54,6 +62,8 @@
|
|||||||
[routerLink]="['/admin']"
|
[routerLink]="['/admin']"
|
||||||
>Admin Control</a
|
>Admin Control</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -65,10 +75,14 @@
|
|||||||
[routerLink]="['/resources']"
|
[routerLink]="['/resources']"
|
||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
</li>
|
||||||
|
<li
|
||||||
*ngIf="
|
*ngIf="
|
||||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
"
|
"
|
||||||
|
class="list-inline-item"
|
||||||
|
>
|
||||||
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -79,6 +93,8 @@
|
|||||||
[routerLink]="['/pricing']"
|
[routerLink]="['/pricing']"
|
||||||
>Pricing</a
|
>Pricing</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -90,6 +106,8 @@
|
|||||||
[routerLink]="['/about']"
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<button
|
<button
|
||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -146,7 +164,8 @@
|
|||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen'
|
'font-weight-bold':
|
||||||
|
currentRoute === 'home' || currentRoute === 'zen'
|
||||||
}"
|
}"
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>Overview</a
|
>Overview</a
|
||||||
@ -198,7 +217,8 @@
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="
|
*ngIf="
|
||||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
hasPermissionForSubscription &&
|
||||||
|
user?.subscription?.type === 'Basic'
|
||||||
"
|
"
|
||||||
class="d-flex d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
@ -218,6 +238,8 @@
|
|||||||
<hr class="d-flex d-sm-none m-0" />
|
<hr class="d-flex d-sm-none m-0" />
|
||||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="user === null">
|
<ng-container *ngIf="user === null">
|
||||||
<a
|
<a
|
||||||
@ -231,6 +253,8 @@
|
|||||||
></gf-logo>
|
></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<ul class="alig-items-center d-flex list-inline m-0">
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -242,6 +266,8 @@
|
|||||||
[routerLink]="['/features']"
|
[routerLink]="['/features']"
|
||||||
>Features</a
|
>Features</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
@ -253,8 +279,9 @@
|
|||||||
[routerLink]="['/about']"
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
@ -264,8 +291,12 @@
|
|||||||
[routerLink]="['/pricing']"
|
[routerLink]="['/pricing']"
|
||||||
>Pricing</a
|
>Pricing</a
|
||||||
>
|
>
|
||||||
<a
|
</li>
|
||||||
|
<li
|
||||||
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||||
|
class="list-inline-item"
|
||||||
|
>
|
||||||
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -276,22 +307,32 @@
|
|||||||
[routerLink]="['/markets']"
|
[routerLink]="['/markets']"
|
||||||
>Markets</a
|
>Markets</a
|
||||||
>
|
>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block no-min-width"
|
class="d-none d-sm-block no-min-width p-1"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
mat-icon-button
|
mat-flat-button
|
||||||
><ion-icon name="logo-github"></ion-icon
|
><ion-icon name="logo-github"></ion-icon
|
||||||
></a>
|
></a>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||||
<ng-container i18n>Sign in</ng-container>
|
<ng-container i18n>Sign in</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a
|
</li>
|
||||||
|
<li
|
||||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||||
|
class="list-inline-item"
|
||||||
|
>
|
||||||
|
<a
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/register']"
|
[routerLink]="['/register']"
|
||||||
><ng-container i18n>Get started</ng-container>
|
><ng-container i18n>Get started</ng-container>
|
||||||
</a>
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -13,8 +13,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.totalBuy"
|
[value]="isLoading ? undefined : summary?.totalBuy"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -24,8 +25,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.totalSell"
|
[value]="isLoading ? undefined : summary?.totalSell"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -38,8 +40,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.committedFunds"
|
[value]="isLoading ? undefined : summary?.committedFunds"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -49,8 +52,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
|
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -79,8 +83,9 @@
|
|||||||
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
|
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.fees"
|
[value]="isLoading ? undefined : summary?.fees"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -93,8 +98,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.currentNetPerformance"
|
[value]="isLoading ? undefined : summary?.currentNetPerformance"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -121,8 +127,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
position="end"
|
position="end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.currentValue"
|
[value]="isLoading ? undefined : summary?.currentValue"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -132,8 +139,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.items"
|
[value]="isLoading ? undefined : summary?.items"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -152,8 +160,9 @@
|
|||||||
></ion-icon>
|
></ion-icon>
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.emergencyFund"
|
[value]="isLoading ? undefined : summary?.emergencyFund"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -163,8 +172,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.cash"
|
[value]="isLoading ? undefined : summary?.cash"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -174,8 +184,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
|
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -183,13 +194,34 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col"><hr /></div>
|
<div class="col"><hr /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
|
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<span
|
||||||
|
*ngIf="summary?.liabilities || summary?.liabilities === 0"
|
||||||
|
class="mr-1"
|
||||||
|
>-</span
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
|
[value]="isLoading ? undefined : summary?.liabilities"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><hr /></div>
|
||||||
|
</div>
|
||||||
<div class="flex-nowrap px-3 py-1 row">
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
|
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
|
||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.netWorth"
|
[value]="isLoading ? undefined : summary?.netWorth"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -217,8 +249,9 @@
|
|||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : summary?.dividend"
|
[value]="isLoading ? undefined : summary?.dividend"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
|
@ -215,6 +215,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.benchmarkDataItems[0].value = this.averagePrice;
|
this.benchmarkDataItems[0].value = this.averagePrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.benchmarkDataItems = this.benchmarkDataItems.map(
|
||||||
|
({ date, value }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
value: value === 0 ? null : value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (Number.isInteger(this.quantity)) {
|
if (Number.isInteger(this.quantity)) {
|
||||||
this.quantityPrecision = 0;
|
this.quantityPrecision = 0;
|
||||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||||
|
@ -12,8 +12,9 @@
|
|||||||
<div class="col-12 d-flex justify-content-center mb-3">
|
<div class="col-12 d-flex justify-content-center mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
size="large"
|
size="large"
|
||||||
[currency]="data.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="data.baseCurrency"
|
||||||
[value]="value"
|
[value]="value"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -40,8 +41,9 @@
|
|||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
[currency]="data.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="data.baseCurrency"
|
||||||
[value]="netPerformance"
|
[value]="netPerformance"
|
||||||
>Change</gf-value
|
>Change</gf-value
|
||||||
>
|
>
|
||||||
@ -61,8 +63,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="SymbolProfile?.currency"
|
||||||
[value]="averagePrice"
|
[value]="averagePrice"
|
||||||
>Average Unit Price</gf-value
|
>Average Unit Price</gf-value
|
||||||
>
|
>
|
||||||
@ -71,8 +74,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="SymbolProfile?.currency"
|
||||||
[value]="marketPrice"
|
[value]="marketPrice"
|
||||||
>Market Price</gf-value
|
>Market Price</gf-value
|
||||||
>
|
>
|
||||||
@ -81,9 +85,10 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||||
|
[unit]="SymbolProfile?.currency"
|
||||||
[value]="minPrice"
|
[value]="minPrice"
|
||||||
>Minimum Price</gf-value
|
>Minimum Price</gf-value
|
||||||
>
|
>
|
||||||
@ -92,9 +97,10 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="SymbolProfile?.currency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||||
|
[unit]="SymbolProfile?.currency"
|
||||||
[value]="maxPrice"
|
[value]="maxPrice"
|
||||||
>Maximum Price</gf-value
|
>Maximum Price</gf-value
|
||||||
>
|
>
|
||||||
@ -113,8 +119,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="data.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="data.baseCurrency"
|
||||||
[value]="investment"
|
[value]="investment"
|
||||||
>Investment</gf-value
|
>Investment</gf-value
|
||||||
>
|
>
|
||||||
@ -123,8 +130,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="data.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="data.baseCurrency"
|
||||||
[value]="dividendInBaseCurrency"
|
[value]="dividendInBaseCurrency"
|
||||||
>Dividend</gf-value
|
>Dividend</gf-value
|
||||||
>
|
>
|
||||||
@ -133,8 +141,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[currency]="data.baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
|
[unit]="data.baseCurrency"
|
||||||
[value]="feeInBaseCurrency"
|
[value]="feeInBaseCurrency"
|
||||||
>Fees</gf-value
|
>Fees</gf-value
|
||||||
>
|
>
|
||||||
|
@ -45,8 +45,9 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
class="mr-3"
|
class="mr-3"
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
[currency]="baseCurrency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
[value]="position?.netPerformance"
|
[value]="position?.netPerformance"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
<gf-value
|
<gf-value
|
||||||
|
@ -22,13 +22,38 @@ const routes: Routes = [
|
|||||||
(m) => m.ChangelogPageModule
|
(m) => m.ChangelogPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
...[
|
||||||
path: 'privacy-policy',
|
'license',
|
||||||
|
/////
|
||||||
|
'licenca',
|
||||||
|
'licence',
|
||||||
|
'licencia',
|
||||||
|
'licentie',
|
||||||
|
'lizenz',
|
||||||
|
'licenza'
|
||||||
|
].map((path) => ({
|
||||||
|
path,
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./license/license-page.module').then(
|
||||||
|
(m) => m.LicensePageModule
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
...[
|
||||||
|
'privacy-policy',
|
||||||
|
/////
|
||||||
|
'datenschutzbestimmungen',
|
||||||
|
'informativa-sulla-privacy',
|
||||||
|
'politique-de-confidentialite',
|
||||||
|
'politica-de-privacidad',
|
||||||
|
'politica-de-privacidade',
|
||||||
|
'privacybeleid'
|
||||||
|
].map((path) => ({
|
||||||
|
path,
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./privacy-policy/privacy-policy-page.module').then(
|
import('./privacy-policy/privacy-policy-page.module').then(
|
||||||
(m) => m.PrivacyPolicyPageModule
|
(m) => m.PrivacyPolicyPageModule
|
||||||
)
|
)
|
||||||
}
|
}))
|
||||||
],
|
],
|
||||||
component: AboutPageComponent,
|
component: AboutPageComponent,
|
||||||
path: '',
|
path: '',
|
||||||
|
@ -53,9 +53,14 @@ export class AboutPageComponent implements OnDestroy, OnInit {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconName: 'sparkles-outline',
|
iconName: 'sparkles-outline',
|
||||||
label: $localize`Changelog & License`,
|
label: $localize`Changelog`,
|
||||||
path: ['/about', 'changelog']
|
path: ['/about', 'changelog']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
iconName: 'ribbon-outline',
|
||||||
|
label: $localize`License`,
|
||||||
|
path: ['/about', 'license']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
iconName: 'shield-checkmark-outline',
|
iconName: 'shield-checkmark-outline',
|
||||||
label: $localize`Privacy Policy`,
|
label: $localize`Privacy Policy`,
|
||||||
|
@ -9,7 +9,7 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
component: ChangelogPageComponent,
|
component: ChangelogPageComponent,
|
||||||
path: '',
|
path: '',
|
||||||
title: $localize`Changelog & License`
|
title: $localize`Changelog`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,23 +1,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1>
|
||||||
<mat-card appearance="outlined" class="changelog">
|
<div class="changelog">
|
||||||
<mat-card-content>
|
|
||||||
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
||||||
</mat-card-content>
|
</div>
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
|
||||||
<mat-card appearance="outlined">
|
|
||||||
<mat-card-content>
|
|
||||||
<markdown [src]="'../assets/LICENSE'"></markdown>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
|
|
||||||
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
|
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
|
||||||
@ -11,8 +10,7 @@ import { ChangelogPageComponent } from './changelog-page.component';
|
|||||||
imports: [
|
imports: [
|
||||||
ChangelogPageRoutingModule,
|
ChangelogPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MarkdownModule.forChild(),
|
MarkdownModule.forChild()
|
||||||
MatCardModule
|
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.mat-mdc-card {
|
.changelog {
|
||||||
&.changelog {
|
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
a {
|
a {
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
@ -35,7 +34,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
color: rgb(var(--light-primary-text));
|
color: rgb(var(--light-primary-text));
|
||||||
|
@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
|
|||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
|
import { LicensePageComponent } from './license-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
component: BlackFriday2022PageComponent,
|
component: LicensePageComponent,
|
||||||
path: '',
|
path: '',
|
||||||
title: 'Black Friday 2022'
|
title: $localize`License`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -17,4 +17,4 @@ const routes: Routes = [
|
|||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
exports: [RouterModule]
|
exports: [RouterModule]
|
||||||
})
|
})
|
||||||
export class BlackFriday2022RoutingModule {}
|
export class LicensePageRoutingModule {}
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
selector: 'gf-license-page',
|
||||||
|
styleUrls: ['./license-page.scss'],
|
||||||
|
templateUrl: './license-page.html'
|
||||||
|
})
|
||||||
|
export class LicensePageComponent implements OnDestroy {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
10
apps/client/src/app/pages/about/license/license-page.html
Normal file
10
apps/client/src/app/pages/about/license/license-page.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-5 row">
|
||||||
|
<div class="col">
|
||||||
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>License</h1>
|
||||||
|
<div>
|
||||||
|
<markdown [src]="'../assets/LICENSE'"></markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,13 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
|
|
||||||
|
import { LicensePageRoutingModule } from './license-page-routing.module';
|
||||||
|
import { LicensePageComponent } from './license-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [LicensePageComponent],
|
||||||
|
imports: [LicensePageRoutingModule, CommonModule, MarkdownModule.forChild()],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class LicensePageModule {}
|
@ -0,0 +1,8 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@ -15,8 +14,8 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './about-overview-page.html'
|
templateUrl: './about-overview-page.html'
|
||||||
})
|
})
|
||||||
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||||
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
|
|
||||||
public hasPermissionForBlog: boolean;
|
public hasPermissionForBlog: boolean;
|
||||||
|
public hasPermissionForStatistics: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public isLoggedIn: boolean;
|
public isLoggedIn: boolean;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -36,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableBlog
|
permissions.enableBlog
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionForStatistics = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.enableStatistics
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionForSubscription = hasPermission(
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
|
@ -19,13 +19,11 @@
|
|||||||
title="GNU Affero General Public License"
|
title="GNU Affero General Public License"
|
||||||
>AGPL-3.0 license</a
|
>AGPL-3.0 license</a
|
||||||
>
|
>
|
||||||
|
<ng-container *ngIf="hasPermissionForStatistics">
|
||||||
and we share aggregated
|
and we share aggregated
|
||||||
<a
|
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
|
||||||
href="https://ghostfol.io/{{ defaultLanguageCode }}/open"
|
of the platform’s performance</ng-container
|
||||||
title="Open Startup"
|
>. The project has been initiated by
|
||||||
>key metrics</a
|
|
||||||
>
|
|
||||||
of the platform’s performance. The project has been initiated by
|
|
||||||
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
||||||
>Thomas Kaul</a
|
>Thomas Kaul</a
|
||||||
>
|
>
|
||||||
@ -53,8 +51,9 @@
|
|||||||
<a
|
<a
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
title="Join the Ghostfolio Slack community"
|
title="Join the Ghostfolio Slack community"
|
||||||
>Slack community</a
|
>Slack</a
|
||||||
>, tweet to
|
>
|
||||||
|
community, tweet to
|
||||||
<a
|
<a
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
title="Tweet to Ghostfolio on Twitter"
|
title="Tweet to Ghostfolio on Twitter"
|
||||||
@ -94,7 +93,7 @@
|
|||||||
class="mx-2"
|
class="mx-2"
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
title="Join the Ghostfolio Slack channel"
|
title="Join the Ghostfolio Slack community"
|
||||||
>
|
>
|
||||||
<ion-icon name="logo-slack"></ion-icon>
|
<ion-icon name="logo-slack"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module';
|
import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module';
|
||||||
import { AboutOverviewPageComponent } from './about-overview-page.component';
|
import { AboutOverviewPageComponent } from './about-overview-page.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AboutOverviewPageComponent],
|
declarations: [AboutOverviewPageComponent],
|
||||||
imports: [AboutOverviewPageRoutingModule, CommonModule, MatButtonModule],
|
imports: [
|
||||||
|
AboutOverviewPageRoutingModule,
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class AboutOverviewPageModule {}
|
export class AboutOverviewPageModule {}
|
||||||
|
@ -156,10 +156,10 @@
|
|||||||
>Nederlands (<ng-container i18n>Community</ng-container
|
>Nederlands (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
<!--<mat-option value="pt"
|
<mat-option value="pt"
|
||||||
>Português (<ng-container i18n>Community</ng-container
|
>Português (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>-->
|
>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -153,6 +153,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
public openUpdateAccountDialog({
|
public openUpdateAccountDialog({
|
||||||
accountType,
|
accountType,
|
||||||
balance,
|
balance,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
id,
|
id,
|
||||||
isExcluded,
|
isExcluded,
|
||||||
@ -164,6 +165,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
account: {
|
account: {
|
||||||
accountType,
|
accountType,
|
||||||
balance,
|
balance,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
id,
|
id,
|
||||||
isExcluded,
|
isExcluded,
|
||||||
@ -232,6 +234,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
account: {
|
account: {
|
||||||
accountType: AccountType.SECURITIES,
|
accountType: AccountType.SECURITIES,
|
||||||
balance: 0,
|
balance: 0,
|
||||||
|
comment: null,
|
||||||
currency: this.user?.settings?.baseCurrency,
|
currency: this.user?.settings?.baseCurrency,
|
||||||
isExcluded: false,
|
isExcluded: false,
|
||||||
name: null,
|
name: null,
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
[(ngModel)]="data.account.balance"
|
[(ngModel)]="data.account.balance"
|
||||||
/>
|
/>
|
||||||
|
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
|
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
|
||||||
@ -50,6 +51,19 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Note</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMinRows="2"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
matInput
|
||||||
|
name="comment"
|
||||||
|
[(ngModel)]="data.account.comment"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div class="mb-3 px-2">
|
<div class="mb-3 px-2">
|
||||||
<mat-checkbox
|
<mat-checkbox
|
||||||
color="primary"
|
color="primary"
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|
||||||
|
|
||||||
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
{
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
component: HalloGhostfolioPageComponent,
|
|
||||||
path: '',
|
|
||||||
title: 'Hallo Ghostfolio'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class HalloGhostfolioPageRoutingModule {}
|
|
@ -1,9 +1,12 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
|
imports: [MatButtonModule, RouterModule],
|
||||||
selector: 'gf-hallo-ghostfolio-page',
|
selector: 'gf-hallo-ghostfolio-page',
|
||||||
styleUrls: ['./hallo-ghostfolio-page.scss'],
|
standalone: true,
|
||||||
templateUrl: './hallo-ghostfolio-page.html'
|
templateUrl: './hallo-ghostfolio-page.html'
|
||||||
})
|
})
|
||||||
export class HalloGhostfolioPageComponent {}
|
export class HalloGhostfolioPageComponent {}
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { HalloGhostfolioPageRoutingModule } from './hallo-ghostfolio-page-routing.module';
|
|
||||||
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [HalloGhostfolioPageComponent],
|
|
||||||
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
})
|
|
||||||
export class HalloGhostfolioPageModule {}
|
|
@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|
||||||
|
|
||||||
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
{
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
component: HelloGhostfolioPageComponent,
|
|
||||||
path: '',
|
|
||||||
title: 'Hello Ghostfolio'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class HelloGhostfolioPageRoutingModule {}
|
|
@ -1,9 +1,12 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
|
imports: [MatButtonModule, RouterModule],
|
||||||
selector: 'gf-hello-ghostfolio-page',
|
selector: 'gf-hello-ghostfolio-page',
|
||||||
styleUrls: ['./hello-ghostfolio-page.scss'],
|
standalone: true,
|
||||||
templateUrl: './hello-ghostfolio-page.html'
|
templateUrl: './hello-ghostfolio-page.html'
|
||||||
})
|
})
|
||||||
export class HelloGhostfolioPageComponent {}
|
export class HelloGhostfolioPageComponent {}
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { HelloGhostfolioPageRoutingModule } from './hello-ghostfolio-page-routing.module';
|
|
||||||
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [HelloGhostfolioPageComponent],
|
|
||||||
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
})
|
|
||||||
export class HelloGhostfolioPageModule {}
|
|
@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|
||||||
|
|
||||||
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
{
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
component: FirstMonthsInOpenSourcePageComponent,
|
|
||||||
path: '',
|
|
||||||
title: 'First months in Open Source'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class FirstMonthsInOpenSourceRoutingModule {}
|
|
@ -1,9 +1,12 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
|
imports: [MatButtonModule, RouterModule],
|
||||||
selector: 'gf-first-months-in-open-source-page',
|
selector: 'gf-first-months-in-open-source-page',
|
||||||
styleUrls: ['./first-months-in-open-source-page.scss'],
|
standalone: true,
|
||||||
templateUrl: './first-months-in-open-source-page.html'
|
templateUrl: './first-months-in-open-source-page.html'
|
||||||
})
|
})
|
||||||
export class FirstMonthsInOpenSourcePageComponent {}
|
export class FirstMonthsInOpenSourcePageComponent {}
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
|
|
||||||
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [FirstMonthsInOpenSourcePageComponent],
|
|
||||||
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
})
|
|
||||||
export class FirstMonthsInOpenSourcePageModule {}
|
|
@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user