Compare commits
38 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
||||
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**
|
||||
|
||||
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- [ ] Cloud
|
||||
- [ ] Self-hosted
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
74
CHANGELOG.md
74
CHANGELOG.md
@ -5,7 +5,76 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.280.0 - 2023-06-10
|
||||
## 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
|
||||
|
||||
@ -799,7 +868,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 investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for Português (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1079,7 +1147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
|
@ -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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -26,11 +28,12 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -245,7 +248,11 @@ export class AdminController {
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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> {
|
||||
if (
|
||||
!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')
|
||||
@ -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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
|
@ -1,21 +1,24 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.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 {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@ -25,6 +28,7 @@ export class AdminService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -35,6 +39,38 @@ export class AdminService {
|
||||
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) {
|
||||
await this.marketDataService.deleteMany({ 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 { 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,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
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[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
@ -129,38 +170,48 @@ export class AdminService {
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
comment: symbolProfile.comment,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
count,
|
||||
marketData: assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
dataSource,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
}) => {
|
||||
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date
|
||||
};
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,12 +249,14 @@ export class AdminService {
|
||||
public async patchAssetProfileData({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
|
@ -104,6 +104,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||
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 (
|
||||
|
@ -204,7 +204,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -375,7 +375,7 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
propertyService
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 } from 'cache-manager';
|
||||
|
||||
@ -13,6 +14,10 @@ export class RedisCacheService {
|
||||
return await this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${dataSource}-${symbol}`;
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
}
|
||||
|
@ -36,10 +36,12 @@ export class SymbolController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
@Query('includeIndices') includeIndices: boolean = false,
|
||||
@Query('query') query = ''
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase(),
|
||||
user: this.request.user
|
||||
});
|
||||
|
@ -81,9 +81,11 @@ export class SymbolService {
|
||||
}
|
||||
|
||||
public async lookup({
|
||||
includeIndices = false,
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
@ -95,6 +97,7 @@ export class SymbolService {
|
||||
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search({
|
||||
includeIndices,
|
||||
query,
|
||||
user
|
||||
});
|
||||
|
@ -166,7 +166,7 @@ export class UserService {
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
|
||||
if (
|
||||
Analytics?.activityCount % 20 === 0 &&
|
||||
Analytics?.activityCount % 10 === 0 &&
|
||||
user.subscription?.type === 'Basic'
|
||||
) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -10,11 +12,12 @@ async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
const configService = configApp.get<ConfigService>(ConfigService);
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: environment.production
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
@ -32,6 +35,22 @@ async function bootstrap() {
|
||||
// Support 10mb csv/json files for importing activities
|
||||
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 HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
|
@ -16,6 +16,7 @@ export class ConfigurationService {
|
||||
default: 'USD'
|
||||
}),
|
||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
||||
|
@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aQuery);
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(query);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
|
@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
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[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${aQuery}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
|
||||
const { coins } = await get();
|
||||
|
||||
items = coins.map(({ id: symbol, name }) => {
|
||||
|
@ -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 { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
@ -235,9 +237,43 @@ export class DataProviderService {
|
||||
} = {};
|
||||
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(
|
||||
itemsGroupedByDataSource
|
||||
@ -271,6 +307,15 @@ export class DataProviderService {
|
||||
result
|
||||
)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
|
||||
this.redisCacheService.set(
|
||||
this.redisCacheService.getQuoteKey({
|
||||
dataSource: DataSource[dataSource],
|
||||
symbol
|
||||
}),
|
||||
JSON.stringify(dataProviderResponse),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
@ -283,7 +328,7 @@ export class DataProviderService {
|
||||
);
|
||||
|
||||
try {
|
||||
await this.marketDataService.updateMany({
|
||||
this.marketDataService.updateMany({
|
||||
data: Object.keys(response)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
@ -322,9 +367,11 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
@ -347,7 +394,12 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return !symbol.endsWith('.FOREX');
|
||||
})
|
||||
.map((symbol) => {
|
||||
return this.search(symbol);
|
||||
return this.search({ query: symbol });
|
||||
})
|
||||
);
|
||||
|
||||
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return 'AAPL.US';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const searchResult = await this.getSearchResult(aQuery);
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const searchResult = await this.getSearchResult(query);
|
||||
|
||||
return {
|
||||
items: searchResult
|
||||
|
@ -143,12 +143,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
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[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
|
||||
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
|
@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
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({
|
||||
select: {
|
||||
assetClass: true,
|
||||
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -42,5 +42,11 @@ export interface DataProviderInterface {
|
||||
|
||||
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(
|
||||
[{ symbol, dataSource: this.getName() }]
|
||||
);
|
||||
const { defaultMarketPrice, selector, url } =
|
||||
symbolProfile.scraperConfiguration ?? {};
|
||||
const {
|
||||
defaultMarketPrice,
|
||||
headers = {},
|
||||
selector,
|
||||
url
|
||||
} = symbolProfile.scraperConfiguration ?? {};
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
const get = bent(url, 'GET', 'string', 200, {});
|
||||
const get = bent(url, 'GET', 'string', 200, headers);
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
|
||||
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({
|
||||
select: {
|
||||
assetClass: true,
|
||||
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
|
||||
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: [] };
|
||||
}
|
||||
|
||||
|
@ -275,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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[] = [];
|
||||
|
||||
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
|
||||
.filter((quote) => {
|
||||
@ -295,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
this.baseCurrency
|
||||
)
|
||||
)) ||
|
||||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
|
||||
quoteTypes.includes(quoteType)
|
||||
);
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
|
@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
BASE_CURRENCY: string;
|
||||
BETTER_UPTIME_API_KEY: string;
|
||||
CACHE_QUOTES_TTL: number;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||
DATA_SOURCE_IMPORT: string;
|
||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
||||
export class SymbolProfileService {
|
||||
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) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
@ -90,11 +96,12 @@ export class SymbolProfileService {
|
||||
public updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: { comment, symbolMapping },
|
||||
data: { comment, scraperConfiguration, symbolMapping },
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
@ -189,6 +196,8 @@ export class SymbolProfileService {
|
||||
if (scraperConfiguration) {
|
||||
return {
|
||||
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
||||
headers:
|
||||
scraperConfiguration.headers as ScraperConfiguration['headers'],
|
||||
selector: scraperConfiguration.selector as string,
|
||||
url: scraperConfiguration.url as string
|
||||
};
|
||||
|
@ -18,36 +18,6 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
@ -168,6 +138,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
|
||||
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/07/exploring-the-path-to-fire',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.module'
|
||||
).then((m) => m.ExploringThePathToFirePageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
@ -179,6 +156,7 @@ const routes: Routes = [
|
||||
'domande-piu-frequenti',
|
||||
'foire-aux-questions',
|
||||
'haeufig-gestellte-fragen',
|
||||
'perguntas-mais-frequentes',
|
||||
'preguntas-mas-frecuentes',
|
||||
'vaak-gestelde-vragen'
|
||||
].map((path) => ({
|
||||
@ -243,6 +221,7 @@ const routes: Routes = [
|
||||
'pricing',
|
||||
/////
|
||||
'precios',
|
||||
'precos',
|
||||
'preise',
|
||||
'prezzi',
|
||||
'prijzen',
|
||||
@ -259,6 +238,7 @@ const routes: Routes = [
|
||||
/////
|
||||
'enregistrement',
|
||||
'iscrizione',
|
||||
'registo',
|
||||
'registratie',
|
||||
'registrierung',
|
||||
'registro'
|
||||
|
@ -89,7 +89,7 @@
|
||||
<li>
|
||||
<a i18n [routerLink]="['/about', 'license']">License</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<li *ngIf="hasPermissionForStatistics">
|
||||
<a [routerLink]="['/open']">Open Startup</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
@ -101,9 +101,13 @@
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
||||
>Status</a
|
||||
>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@ -112,24 +116,30 @@
|
||||
<ul class="list-unstyled">
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
target="_blank"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>GitHub</a
|
||||
>
|
||||
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
target="_blank"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack</a
|
||||
>
|
||||
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
>Twitter</a
|
||||
>
|
||||
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<li>
|
||||
@ -150,6 +160,9 @@
|
||||
<li>
|
||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public info: InfoItem;
|
||||
@ -70,6 +71,11 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@ -7,23 +8,26 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatSort, Sort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
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 { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
|
||||
styleUrls: ['./admin-market-data.scss'],
|
||||
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;
|
||||
|
||||
public activeFilters: Filter[] = [];
|
||||
@ -73,6 +80,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public isLoading = false;
|
||||
public placeholder = '';
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public totalItems = 0;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -80,7 +89,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
@ -99,6 +107,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
} else if (params['createAssetProfileDialog']) {
|
||||
this.openCreateAssetProfileDialog();
|
||||
}
|
||||
});
|
||||
|
||||
@ -113,34 +123,40 @@ 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() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
}
|
||||
|
||||
this.filters$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((filters) => {
|
||||
this.isLoading = true;
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||
|
||||
return this.dataService.fetchAdminMarketData({
|
||||
filters: this.activeFilters
|
||||
});
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ marketData }) => {
|
||||
this.dataSource = new MatTableDataSource(marketData);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.loadData({
|
||||
pageIndex: page.pageIndex,
|
||||
sortColumn: this.sort.active,
|
||||
sortDirection: <Prisma.SortOrder>this.sort.direction
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
@ -208,6 +224,47 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
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({
|
||||
dataSource,
|
||||
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 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>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@ -74,7 +74,7 @@
|
||||
</ng-container>
|
||||
|
||||
<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>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
@ -83,7 +83,7 @@
|
||||
</ng-container>
|
||||
|
||||
<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>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
@ -92,7 +92,7 @@
|
||||
</ng-container>
|
||||
|
||||
<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>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
@ -162,6 +162,40 @@
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||
></tr>
|
||||
</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 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>
|
||||
|
@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataComponent],
|
||||
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -2,4 +2,11 @@
|
||||
|
||||
:host {
|
||||
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 {
|
||||
AdminMarketDataDetails,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
scraperConfiguration: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
scraperConfiguration: JSON.stringify(
|
||||
this.assetProfile?.scraperConfiguration ?? {}
|
||||
),
|
||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||
});
|
||||
|
||||
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
let scraperConfiguration = {};
|
||||
let symbolMapping = {};
|
||||
|
||||
try {
|
||||
scraperConfiguration = JSON.parse(
|
||||
this.assetProfileForm.controls['scraperConfiguration'].value
|
||||
);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
symbolMapping = JSON.parse(
|
||||
this.assetProfileForm.controls['symbolMapping'].value
|
||||
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
scraperConfiguration,
|
||||
symbolMapping,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||
};
|
||||
|
@ -162,6 +162,17 @@
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</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>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<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 { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private cacheService: CacheService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private fetchAdminData() {
|
||||
this.dataService
|
||||
this.adminService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.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>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private fetchAdminData() {
|
||||
this.dataService
|
||||
this.adminService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ users }) => {
|
||||
|
@ -25,6 +25,7 @@ const routes: Routes = [
|
||||
...[
|
||||
'license',
|
||||
/////
|
||||
'licenca',
|
||||
'licence',
|
||||
'licencia',
|
||||
'licentie',
|
||||
@ -37,13 +38,22 @@ const routes: Routes = [
|
||||
(m) => m.LicensePageModule
|
||||
)
|
||||
})),
|
||||
{
|
||||
path: 'privacy-policy',
|
||||
...[
|
||||
'privacy-policy',
|
||||
/////
|
||||
'datenschutzbestimmungen',
|
||||
'informativa-sulla-privacy',
|
||||
'politique-de-confidentialite',
|
||||
'politica-de-privacidad',
|
||||
'politica-de-privacidade',
|
||||
'privacybeleid'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./privacy-policy/privacy-policy-page.module').then(
|
||||
(m) => m.PrivacyPolicyPageModule
|
||||
)
|
||||
}
|
||||
}))
|
||||
],
|
||||
component: AboutPageComponent,
|
||||
path: '',
|
||||
|
@ -15,6 +15,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public isLoggedIn: boolean;
|
||||
public user: User;
|
||||
@ -34,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
permissions.enableBlog
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
|
@ -19,9 +19,11 @@
|
||||
title="GNU Affero General Public License"
|
||||
>AGPL-3.0 license</a
|
||||
>
|
||||
and we share aggregated
|
||||
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
|
||||
of the platform’s performance. The project has been initiated by
|
||||
<ng-container *ngIf="hasPermissionForStatistics">
|
||||
and we share aggregated
|
||||
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
|
||||
of the platform’s performance</ng-container
|
||||
>. The project has been initiated by
|
||||
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
||||
>Thomas Kaul</a
|
||||
>
|
||||
|
@ -156,10 +156,10 @@
|
||||
>Nederlands (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--<mat-option value="pt"
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>-->
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -37,6 +37,7 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.account.balance"
|
||||
/>
|
||||
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
|
||||
|
@ -84,13 +84,13 @@
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
To participate in the ongoing development of Ghostfolio, please feel
|
||||
free to reach out to us on our
|
||||
free to contact us in the
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
target="_blank"
|
||||
>Slack channel</a
|
||||
>Slack</a
|
||||
>
|
||||
or via Twitter
|
||||
community or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
|
@ -92,12 +92,14 @@
|
||||
<p>
|
||||
Achieving
|
||||
<a [routerLink]="['/resources']">financial independence</a>
|
||||
including early retirement (FIRE) requires careful planning,
|
||||
monitoring, and forecasting. Ghostfolio’s robust features equip
|
||||
individuals with tools to analyze, optimize and simulate investment
|
||||
strategies. By providing insights, performance tracking, and
|
||||
portfolio analysis, Ghostfolio serves as a valuable companion in the
|
||||
pursuit of financial freedom.
|
||||
including early retirement (<a
|
||||
href="../en/blog/2023/07/exploring-the-path-to-fire"
|
||||
>FIRE</a
|
||||
>) requires careful planning, monitoring, and forecasting.
|
||||
Ghostfolio’s robust features equip individuals with tools to
|
||||
analyze, optimize and simulate investment strategies. By providing
|
||||
insights, performance tracking, and portfolio analysis, Ghostfolio
|
||||
serves as a valuable companion in the pursuit of financial freedom.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { ExploringThePathToFirePageComponent } from './exploring-the-path-to-fire-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: ExploringThePathToFirePageComponent,
|
||||
path: '',
|
||||
title: 'Exploring the Path to FIRE'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ExploringThePathToFireRoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-exploring-the-path-to-fire-page-page',
|
||||
styleUrls: ['./exploring-the-path-to-fire-page.scss'],
|
||||
templateUrl: './exploring-the-path-to-fire-page.html'
|
||||
})
|
||||
export class ExploringThePathToFirePageComponent {}
|
@ -0,0 +1,257 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1">
|
||||
Exploring the Path to Financial Independence and Retiring Early
|
||||
(FIRE)
|
||||
</h1>
|
||||
<div class="mb-3 text-muted"><small>2023-07-01</small></div>
|
||||
<img
|
||||
alt="Exploring the Path to Financial Independence and Retiring Early (FIRE) Teaser"
|
||||
class="border rounded w-100"
|
||||
src="../assets/images/blog/20230701.jpg"
|
||||
title="Exploring the Path to Financial Independence and Retiring Early (FIRE)"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
In this article, we will explore the concept behind FIRE and
|
||||
thoroughly discuss the pros and cons of this increasingly popular
|
||||
movement.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">The Idea behind FIRE</h2>
|
||||
<p>
|
||||
FIRE, short for <i>Financial Independence and Retiring Early</i>, is
|
||||
a movement that promotes the idea of saving and investing a
|
||||
significant portion of one’s income in order to achieve financial
|
||||
independence and retire at a young age. The core principle of FIRE
|
||||
is not unconditionally quitting work, but rather attaining the
|
||||
freedom to pursue meaningful activities and experiences without
|
||||
relying on traditional employment.
|
||||
</p>
|
||||
<p>
|
||||
At the heart of FIRE lies the famous 4% rule, originated in the
|
||||
<a href="https://en.wikipedia.org/wiki/Trinity_study"
|
||||
>Trinity study</a
|
||||
>. This rule suggests that if you withdraw 4% of your investment
|
||||
portfolio (usually based on low-cost index funds) in the first year
|
||||
of retirement and adjust that amount for inflation in subsequent
|
||||
years, your savings have a high likelihood of lasting for a 30-year
|
||||
retirement period. The 4% rule serves as a guide to determine the
|
||||
amount that can be safely withdrawn from a retirement portfolio each
|
||||
year without depleting savings prematurely, allowing individuals to
|
||||
sustain their desired lifestyle throughout retirement.
|
||||
</p>
|
||||
<p>
|
||||
By multiplying your anticipated annual expenses by 25, you obtain an
|
||||
estimate of the total retirement savings required to sustain your
|
||||
lifestyle, assuming a 4% withdrawal rate.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">The power of FIRE</h2>
|
||||
<p>
|
||||
FIRE grants individuals the freedom to make choices based on their
|
||||
personal values, liberated from financial constraints. By achieving
|
||||
financial independence, individuals gain control over their time and
|
||||
resources, enabling them to live life on their terms.
|
||||
</p>
|
||||
<p>
|
||||
One of the primary benefits of FIRE is the ability to retire early,
|
||||
affording individuals the opportunity to pursue their passions,
|
||||
spend quality time with loved ones, engage in meaningful projects,
|
||||
and prioritize personal growth. Early retirement promotes a more
|
||||
balanced lifestyle and allows individuals to savor life while they
|
||||
are still young and full of energy.
|
||||
</p>
|
||||
<p>
|
||||
FIRE encourages adopting a frugal mindset and intentional spending,
|
||||
resulting in reduced financial stress. By living within their means
|
||||
and focusing on what truly brings them joy, individuals experience
|
||||
greater peace of mind, improved well-being, and a stronger sense of
|
||||
financial security.
|
||||
</p>
|
||||
<p>
|
||||
FIRE grants individuals a higher level of autonomy and empowerment
|
||||
over their schedules and the activities they choose to pursue.
|
||||
Whether it involves exploring new career paths, starting a business,
|
||||
or embarking on extensive travel, FIRE provides the flexibility to
|
||||
determine how time is spent and how life is shaped.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">The challenges of FIRE</h2>
|
||||
<p>
|
||||
Achieving FIRE often demands strict budgeting, aggressive saving,
|
||||
and making sacrifices for years to secure a financially independent
|
||||
future. This may involve cutting back on discretionary expenses,
|
||||
deferring major purchases, or adopting a simpler lifestyle to
|
||||
achieve long-term goals.
|
||||
</p>
|
||||
<p>
|
||||
FIRE heavily relies on investments and the performance of financial
|
||||
markets. Economic downturns or unexpected market fluctuations can
|
||||
impact investment portfolios and potentially delay or jeopardize
|
||||
early retirement plans. Developing a robust financial plan,
|
||||
diversifying investments, and periodically reassessing your
|
||||
withdrawal strategy is crucial.
|
||||
</p>
|
||||
<p>
|
||||
This particular aspect of FIRE requires thoughtful deliberation and
|
||||
strategic planning to ensure a consistent and reliable stream of
|
||||
retirement income throughout one’s lifespan.
|
||||
</p>
|
||||
<p>
|
||||
Retiring early often means stepping away from a career and
|
||||
potentially losing the professional identity and social connections
|
||||
associated with it. Giving careful thought to how early retirement
|
||||
might influence one’s sense of purpose is crucial, as it prompts the
|
||||
exploration of alternative avenues to stay engaged and fulfilled.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Achieving FIRE for a fulfilling future</h2>
|
||||
<p>
|
||||
Financial Independence and Retiring Early (FIRE) is a fascinating
|
||||
concept that has gained significant traction in recent years. It
|
||||
offers individuals the opportunity to break free from financial
|
||||
constraints, pursue their passions, and gain control over their time
|
||||
and resources. While FIRE comes with its own set of challenges, the
|
||||
potential benefits are substantial. Whether you choose to fully
|
||||
embrace FIRE or adapt its principles to suit your personal goals,
|
||||
it’s crucial to approach it with careful planning and a clear
|
||||
understanding of the advantages and disadvantages.
|
||||
</p>
|
||||
<p>
|
||||
Knowing your financial situation and tracking it diligently is vital
|
||||
on your journey to FIRE. This is where the power of
|
||||
<a href="https://ghostfol.io">Ghostfolio</a>, a comprehensive open
|
||||
source wealth management software, comes into play. By leveraging
|
||||
Ghostfolio, you can gain deep insights into your financial health,
|
||||
track your investments, and make informed decisions to accelerate
|
||||
your progress towards financial independence. Ghostfolio also
|
||||
provides a dedicated
|
||||
<a [routerLink]="['/features']">FIRE calculator</a>, allowing you to
|
||||
simulate your customized plan to achieve FIRE. You get the tools to
|
||||
optimize your financial journey and confidently strive for a future
|
||||
that is both personally fulfilling and financially secure.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4 py-3">
|
||||
<h2 class="h4 mb-0 text-center">
|
||||
Are you ready for your <strong>journey</strong> towards
|
||||
<strong>FIRE</strong>?
|
||||
</h2>
|
||||
<p class="lead mb-2 text-center">
|
||||
Ghostfolio accompanies you on your path to financial independence.
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<a color="primary" href="https://ghostfol.io" mat-flat-button>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Budgeting</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Calculator</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">FIRE</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Frugality</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Health</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Independence</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Index Fund</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Lifestyle</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Movement</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Planning</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Retirement</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Savings</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Simulation</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Trinity Study</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Withdrawal</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Work-Life Balance</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||
</li>
|
||||
<li
|
||||
aria-current="page"
|
||||
class="active breadcrumb-item text-truncate"
|
||||
>
|
||||
Exploring the Path to Financial Independence and Retiring Early
|
||||
(FIRE)
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { ExploringThePathToFireRoutingModule } from './exploring-the-path-to-fire-page-routing.module';
|
||||
import { ExploringThePathToFirePageComponent } from './exploring-the-path-to-fire-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ExploringThePathToFirePageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExploringThePathToFireRoutingModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class ExploringThePathToFirePageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -2,6 +2,33 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2023/07/exploring-the-path-to-fire"
|
||||
>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
Exploring the Path to Financial Independence and Retiring
|
||||
Early (FIRE)
|
||||
</div>
|
||||
<div class="d-flex text-muted">2023-07-01</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Features</h3>
|
||||
<div class="mb-4">
|
||||
<p>
|
||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||
@ -13,7 +13,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Stocks</h4>
|
||||
<h4 i18n>Stocks</h4>
|
||||
<p class="m-0">Keep track of your stock purchases and sales.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -23,7 +23,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>ETFs</h4>
|
||||
<h4 i18n>ETFs</h4>
|
||||
<p class="m-0">
|
||||
Are you into ETFs (Exchange Traded Funds)? Track your ETF
|
||||
investments.
|
||||
@ -36,7 +36,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Bonds</h4>
|
||||
<h4 i18n>Bonds</h4>
|
||||
<p class="m-0">
|
||||
Manage your investment in bonds and other assets with fixed
|
||||
income.
|
||||
@ -49,7 +49,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Cryptocurrencies</h4>
|
||||
<h4 i18n>Cryptocurrencies</h4>
|
||||
<p class="m-0">
|
||||
Keep track of your Bitcoin and Altcoin holdings.
|
||||
</p>
|
||||
@ -61,7 +61,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Dividend</h4>
|
||||
<h4 i18n>Dividend</h4>
|
||||
<p class="m-0">
|
||||
Are you building a dividend portfolio? Track your dividend in
|
||||
Ghostfolio.
|
||||
@ -74,7 +74,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Wealth Items</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Wealth Items</h4>
|
||||
<p class="m-0">
|
||||
Track all your treasuries, be it your luxury watch or rare
|
||||
trading cards.
|
||||
@ -87,7 +87,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Emergency Fund</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Emergency Fund</h4>
|
||||
<p class="m-0">
|
||||
Define your emergency fund you are comfortable with for
|
||||
difficult times.
|
||||
@ -100,7 +100,22 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Import and Export</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Liabilities</h4>
|
||||
<p class="m-0">
|
||||
Manage your financial liabilities, such as your student loan,
|
||||
to stay ahead of your financial obligations.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex" i18n>
|
||||
Import and Export
|
||||
</h4>
|
||||
<p class="m-0">Import and export your investment activities.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -110,7 +125,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Accounts</h4>
|
||||
<h4 i18n>Multi-Accounts</h4>
|
||||
<p class="m-0">
|
||||
Keep an eye on all your accounts across multiple platforms
|
||||
(multi-banking).
|
||||
@ -124,7 +139,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Portfolio Calculations</span>
|
||||
<span i18n>Portfolio Calculations</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -144,7 +159,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Portfolio Allocations</span>
|
||||
<span i18n>Portfolio Allocations</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -162,7 +177,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Dark Mode</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
|
||||
<p class="m-0">
|
||||
Ghostfolio automatically switches to a dark color theme based
|
||||
on your operating system's preferences.
|
||||
@ -175,7 +190,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Zen Mode</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Zen Mode</h4>
|
||||
<p class="m-0">
|
||||
Keep calm and activate Zen Mode if the markets are going
|
||||
crazy.
|
||||
@ -192,7 +207,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Market Mood</span>
|
||||
<span i18n>Market Mood</span>
|
||||
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
@ -210,7 +225,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Static Analysis</span>
|
||||
<span i18n>Static Analysis</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -228,13 +243,11 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Language</h4>
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English, Dutch, French,
|
||||
German, Italian<ng-container *ngIf="false"
|
||||
>, Portuguese</ng-container
|
||||
>
|
||||
and Spanish are currently supported.
|
||||
German, Italian, Portuguese and Spanish are currently
|
||||
supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -244,7 +257,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Community</h4>
|
||||
<h4 i18n>Community</h4>
|
||||
<p class="m-0">
|
||||
Join the Ghostfolio
|
||||
<a
|
||||
@ -263,7 +276,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Open Source Software</h4>
|
||||
<h4 i18n>Open Source Software</h4>
|
||||
<p class="m-0">
|
||||
The source code is fully available as
|
||||
<a
|
||||
@ -282,9 +295,9 @@
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" mat-flat-button [routerLink]="['/register']">
|
||||
Get Started
|
||||
</a>
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']"
|
||||
>Get Started</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -44,14 +44,13 @@
|
||||
|
||||
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Monthly Active Users (MAU)"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
@ -61,24 +60,6 @@
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Contributors on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubContributors ?? '-'"
|
||||
>Contributors on GitHub</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
@ -86,7 +67,7 @@
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Stars on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="star-outline"
|
||||
@ -103,7 +84,7 @@
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Pulls on Docker Hub"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="cloud-download-outline"
|
||||
|
@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public currencies: string[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public defaultDateFormat: string;
|
||||
public filteredLookupItems: LookupItem[] = [];
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
@ -120,10 +118,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
|
||||
quantity: [this.data.activity?.quantity, Validators.required],
|
||||
searchSymbol: [
|
||||
{
|
||||
dataSource: this.data.activity?.SymbolProfile?.dataSource,
|
||||
symbol: this.data.activity?.SymbolProfile?.symbol
|
||||
},
|
||||
!!this.data.activity?.SymbolProfile
|
||||
? {
|
||||
dataSource: this.data.activity?.SymbolProfile?.dataSource,
|
||||
symbol: this.data.activity?.SymbolProfile?.symbol
|
||||
}
|
||||
: null,
|
||||
Validators.required
|
||||
],
|
||||
tags: [
|
||||
@ -238,28 +238,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||
'searchSymbol'
|
||||
].valueChanges.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query: string) => {
|
||||
if (isString(query) && query.length > 1) {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
|
||||
if (this.activityForm.controls['searchSymbol'].invalid) {
|
||||
this.data.activity.SymbolProfile = null;
|
||||
} else {
|
||||
this.activityForm.controls['dataSource'].setValue(
|
||||
this.activityForm.controls['searchSymbol'].value.dataSource
|
||||
);
|
||||
|
||||
filteredLookupItemsObservable
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
this.updateSymbol();
|
||||
}
|
||||
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
@ -393,25 +384,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onBlurSymbol() {
|
||||
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
|
||||
return (
|
||||
lookupItem.symbol ===
|
||||
this.activityForm.controls['searchSymbol'].value.symbol
|
||||
);
|
||||
});
|
||||
|
||||
if (currentLookupItem) {
|
||||
this.updateSymbol(currentLookupItem.symbol);
|
||||
} else {
|
||||
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
|
||||
|
||||
this.data.activity.SymbolProfile = null;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -455,13 +427,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.dialogRef.close({ activity });
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.controls['dataSource'].setValue(
|
||||
event.option.value.dataSource
|
||||
);
|
||||
this.updateSymbol(event.option.value.symbol);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
@ -477,12 +442,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private updateSymbol(symbol: string) {
|
||||
private updateSymbol() {
|
||||
this.isLoading = true;
|
||||
|
||||
this.activityForm.controls['searchSymbol'].setErrors(null);
|
||||
this.activityForm.controls['searchSymbol'].setValue({ symbol });
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
this.dataService
|
||||
|
@ -48,34 +48,10 @@
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
<gf-symbol-autocomplete
|
||||
formControlName="searchSymbol"
|
||||
matInput
|
||||
[matAutocomplete]="symbolAutocomplete"
|
||||
(blur)="onBlurSymbol()"
|
||||
[isLoading]="isLoading"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#symbolAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
||||
class="line-height-1"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span><b>{{ lookupItem.name }}</b></span>
|
||||
<br />
|
||||
<small class="text-muted"
|
||||
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
|
||||
}}</small
|
||||
>
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div
|
||||
|
@ -9,9 +9,8 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
|
||||
@ -21,7 +20,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolModule,
|
||||
GfSymbolAutocompleteModule,
|
||||
GfValueModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
@ -31,7 +30,6 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
|
@ -41,6 +41,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
public errorMessages: string[] = [];
|
||||
public holdings: Position[] = [];
|
||||
public importStep: ImportStep = ImportStep.UPLOAD_FILE;
|
||||
public isLoading = false;
|
||||
public maxSafeInteger = Number.MAX_SAFE_INTEGER;
|
||||
public mode: 'DIVIDEND';
|
||||
public selectedActivities: Activity[] = [];
|
||||
@ -73,6 +74,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.data?.activityTypes?.length === 1 &&
|
||||
this.data?.activityTypes?.[0] === 'DIVIDEND'
|
||||
) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.dialogTitle = $localize`Import Dividends`;
|
||||
this.mode = 'DIVIDEND';
|
||||
this.uniqueAssetForm.controls['uniqueAsset'].disable();
|
||||
@ -94,6 +97,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
});
|
||||
this.uniqueAssetForm.controls['uniqueAsset'].enable();
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -32,10 +32,14 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Holding</mat-label>
|
||||
<mat-select formControlName="uniqueAsset">
|
||||
<mat-select-trigger
|
||||
>{{ uniqueAssetForm.controls['uniqueAsset']?.value?.name
|
||||
}}</mat-select-trigger
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let holding of holdings"
|
||||
class="line-height-1"
|
||||
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
|
||||
[value]="{ dataSource: holding.dataSource, name: holding.name, symbol: holding.symbol }"
|
||||
>
|
||||
<span><b>{{ holding.name }}</b></span>
|
||||
<br />
|
||||
@ -45,6 +49,11 @@
|
||||
>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-spinner
|
||||
*ngIf="isLoading"
|
||||
class="position-absolute"
|
||||
[diameter]="20"
|
||||
></mat-spinner>
|
||||
</mat-form-field>
|
||||
<div class="d-flex flex-column justify-content-center">
|
||||
<button
|
||||
|
@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
@ -27,6 +28,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
MatDialogModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatStepperModule,
|
||||
ReactiveFormsModule
|
||||
|
@ -27,4 +27,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-progress-spinner {
|
||||
right: 1.5rem;
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
}
|
||||
|
@ -142,6 +142,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">FIRE</h3>
|
||||
<div class="mb-1">
|
||||
FIRE is a movement that promotes saving and investing to achieve
|
||||
financial independence and early retirement.
|
||||
</div>
|
||||
<div>
|
||||
<a href="../en/blog/2023/07/exploring-the-path-to-fire">FIRE →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Inflation</h3>
|
||||
|
@ -7,21 +7,35 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminJobs,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData, Platform } from '@prisma/client';
|
||||
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
|
||||
import { JobStatus } from 'bull';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AdminService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
private http: HttpClient
|
||||
) {}
|
||||
|
||||
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.http.post<void>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public deleteJob(aId: string) {
|
||||
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
|
||||
@ -49,6 +63,44 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchAdminData() {
|
||||
return this.http.get<AdminData>('/api/v1/admin');
|
||||
}
|
||||
|
||||
public fetchAdminMarketData({
|
||||
filters,
|
||||
skip,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
take
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take: number;
|
||||
}) {
|
||||
let params = this.dataService.buildFiltersAsQueryParams({ filters });
|
||||
|
||||
if (skip) {
|
||||
params = params.append('skip', skip);
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
params = params.append('sortColumn', sortColumn);
|
||||
}
|
||||
|
||||
if (sortDirection) {
|
||||
params = params.append('sortDirection', sortDirection);
|
||||
}
|
||||
|
||||
params = params.append('take', take);
|
||||
|
||||
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
public fetchAdminMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
@ -139,12 +191,13 @@ export class AdminService {
|
||||
public patchAssetProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: UniqueAsset & UpdateAssetProfileDto) {
|
||||
return this.http.patch<EnhancedSymbolProfile>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
{ comment, symbolMapping }
|
||||
{ comment, scraperConfiguration, symbolMapping }
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -18,8 +18,6 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Access,
|
||||
Accounts,
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
Export,
|
||||
@ -51,6 +49,67 @@ import { map } from 'rxjs/operators';
|
||||
export class DataService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount) {
|
||||
params = params.append(
|
||||
'accounts',
|
||||
filtersByAccount
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetClass) {
|
||||
params = params.append(
|
||||
'assetClasses',
|
||||
filtersByAssetClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
params = params.append(
|
||||
'assetSubClasses',
|
||||
filtersByAssetSubClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByTag) {
|
||||
params = params.append(
|
||||
'tags',
|
||||
filtersByTag
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
public createCheckoutSession({
|
||||
couponId,
|
||||
priceId
|
||||
@ -92,16 +151,6 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchAdminData() {
|
||||
return this.http.get<AdminData>('/api/v1/admin');
|
||||
}
|
||||
|
||||
public fetchAdminMarketData({ filters }: { filters?: Filter[] }) {
|
||||
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
});
|
||||
}
|
||||
|
||||
public fetchDividends({
|
||||
filters,
|
||||
groupBy = 'month',
|
||||
@ -261,9 +310,21 @@ export class DataService {
|
||||
});
|
||||
}
|
||||
|
||||
public fetchSymbols(aQuery: string) {
|
||||
public fetchSymbols({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}) {
|
||||
let params = new HttpParams().set('query', query);
|
||||
|
||||
if (includeIndices) {
|
||||
params = params.append('includeIndices', includeIndices);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ items: LookupItem[] }>(`/api/v1/symbol/lookup?query=${aQuery}`)
|
||||
.get<{ items: LookupItem[] }>('/api/v1/symbol/lookup', { params })
|
||||
.pipe(
|
||||
map((respose) => {
|
||||
return respose.items;
|
||||
@ -438,65 +499,4 @@ export class DataService {
|
||||
couponCode
|
||||
});
|
||||
}
|
||||
|
||||
private buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount) {
|
||||
params = params.append(
|
||||
'accounts',
|
||||
filtersByAccount
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetClass) {
|
||||
params = params.append(
|
||||
'assetClasses',
|
||||
filtersByAssetClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
params = params.append(
|
||||
'assetSubClasses',
|
||||
filtersByAssetSubClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByTag) {
|
||||
params = params.append(
|
||||
'tags',
|
||||
filtersByTag
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
BIN
apps/client/src/assets/images/blog/20230701.jpg
Normal file
BIN
apps/client/src/assets/images/blog/20230701.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 121 KiB |
@ -6,354 +6,410 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/features</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/maerkte</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/preise</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/registrierung</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/license</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/faq</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/features</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/markets</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/pricing</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/register</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/funcionalidades</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/mercados</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/precios</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/recursos</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/registro</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/enregistrement</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/marches</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/prix</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/funzionalita</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/iscrizione</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/mercati</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/prezzi</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/kenmerken</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/markten</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/registratie</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/blog</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/open</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/precos</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/registo</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface AdminMarketData {
|
||||
count: number;
|
||||
marketData: AdminMarketDataItem[];
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
export interface ScraperConfiguration {
|
||||
defaultMarketPrice?: number;
|
||||
headers?: { [key: string]: string };
|
||||
selector: string;
|
||||
url: string;
|
||||
}
|
||||
|
@ -562,14 +562,13 @@
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
showFirstLastButtons="true"
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
isLoading ||
|
||||
dataSource.data.length === 0 ||
|
||||
(isLoading && dataSource.data.length === 0) ||
|
||||
dataSource.data.length <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
|
@ -206,7 +206,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
} else if (
|
||||
this.hasPermissionToOpenDetails &&
|
||||
!activity.isDraft &&
|
||||
activity.type !== 'ITEM'
|
||||
activity.type !== 'ITEM' &&
|
||||
activity.type !== 'LIABILITY'
|
||||
) {
|
||||
this.onOpenPositionDialog({
|
||||
dataSource: activity.SymbolProfile.dataSource,
|
||||
|
@ -92,7 +92,8 @@
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Allocation</ng-container>
|
||||
<span class="d-none d-sm-block" i18n>Allocation</span>
|
||||
<span class="d-block d-sm-none" title="Allocation">%</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
@ -108,17 +109,14 @@
|
||||
<ng-container matColumnDef="performance">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 justify-content-end"
|
||||
class="justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="netPerformancePercent"
|
||||
>
|
||||
<ng-container i18n>Performance</ng-container>
|
||||
<span class="d-none d-sm-block" i18n>Performance</span>
|
||||
<span class="d-block d-sm-none" title="Performance">±</span>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[colorizeSign]="true"
|
||||
|
178
libs/ui/src/lib/symbol-autocomplete/abstract-mat-form-field.ts
Normal file
178
libs/ui/src/lib/symbol-autocomplete/abstract-mat-form-field.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import {
|
||||
Component,
|
||||
DoCheck,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NgControl } from '@angular/forms';
|
||||
import { MatFormFieldControl } from '@angular/material/form-field';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
template: ''
|
||||
})
|
||||
export abstract class AbstractMatFormField<T>
|
||||
implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy
|
||||
{
|
||||
@HostBinding()
|
||||
public id = `${this.controlType}-${AbstractMatFormField.nextId++}`;
|
||||
|
||||
@HostBinding('attr.aria-describedBy') public describedBy = '';
|
||||
|
||||
public readonly autofilled: boolean;
|
||||
public errorState: boolean;
|
||||
public focused = false;
|
||||
public readonly stateChanges = new Subject<void>();
|
||||
public readonly userAriaDescribedBy: string;
|
||||
|
||||
protected onChange?: (value: T) => void;
|
||||
protected onTouched?: () => void;
|
||||
|
||||
private static nextId: number = 0;
|
||||
|
||||
protected constructor(
|
||||
protected _elementRef: ElementRef,
|
||||
protected _focusMonitor: FocusMonitor,
|
||||
public readonly ngControl: NgControl
|
||||
) {
|
||||
if (this.ngControl) {
|
||||
this.ngControl.valueAccessor = this;
|
||||
}
|
||||
|
||||
_focusMonitor
|
||||
.monitor(this._elementRef.nativeElement, true)
|
||||
.subscribe((origin) => {
|
||||
this.focused = !!origin;
|
||||
this.stateChanges.next();
|
||||
});
|
||||
}
|
||||
|
||||
private _controlType: string;
|
||||
|
||||
public get controlType(): string {
|
||||
return this._controlType;
|
||||
}
|
||||
|
||||
protected set controlType(value: string) {
|
||||
this._controlType = value;
|
||||
this.id = `${this._controlType}-${AbstractMatFormField.nextId++}`;
|
||||
}
|
||||
|
||||
private _value: T;
|
||||
|
||||
public get value(): T {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
public set value(value: T) {
|
||||
this._value = value;
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
public get empty(): boolean {
|
||||
return !this._value;
|
||||
}
|
||||
|
||||
public _placeholder: string = '';
|
||||
|
||||
public get placeholder() {
|
||||
return this._placeholder;
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set placeholder(placeholder: string) {
|
||||
this._placeholder = placeholder;
|
||||
this.stateChanges.next();
|
||||
}
|
||||
|
||||
public _required: boolean = false;
|
||||
|
||||
public get required() {
|
||||
return this._required;
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set required(required: any) {
|
||||
this._required = coerceBooleanProperty(required);
|
||||
this.stateChanges.next();
|
||||
}
|
||||
|
||||
public _disabled: boolean = false;
|
||||
|
||||
public get disabled() {
|
||||
if (this.ngControl && this.ngControl.disabled !== null) {
|
||||
return this.ngControl.disabled;
|
||||
}
|
||||
|
||||
return this._disabled;
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set disabled(disabled: any) {
|
||||
this._disabled = coerceBooleanProperty(disabled);
|
||||
|
||||
if (this.focused) {
|
||||
this.focused = false;
|
||||
this.stateChanges.next();
|
||||
}
|
||||
}
|
||||
|
||||
public abstract focus(): void;
|
||||
|
||||
public get shouldLabelFloat(): boolean {
|
||||
return this.focused || !this.empty;
|
||||
}
|
||||
|
||||
public ngDoCheck(): void {
|
||||
if (this.ngControl) {
|
||||
this.errorState = this.ngControl.invalid && this.ngControl.touched;
|
||||
this.stateChanges.next();
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
this.stateChanges.complete();
|
||||
this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
|
||||
}
|
||||
|
||||
public registerOnChange(fn: (_: T) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
public registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
public setDescribedByIds(ids: string[]): void {
|
||||
this.describedBy = ids.join(' ');
|
||||
}
|
||||
|
||||
public writeValue(value: T): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@HostListener('focusout')
|
||||
public onBlur() {
|
||||
this.focused = false;
|
||||
|
||||
if (this.onTouched) {
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
this.stateChanges.next();
|
||||
}
|
||||
|
||||
public onContainerClick(): void {
|
||||
if (!this.focused) {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
}
|
1
libs/ui/src/lib/symbol-autocomplete/index.ts
Normal file
1
libs/ui/src/lib/symbol-autocomplete/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './symbol-autocomplete.module';
|
@ -0,0 +1,37 @@
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
matInput
|
||||
[formControl]="control"
|
||||
[matAutocomplete]="symbolAutocomplete"
|
||||
/>
|
||||
|
||||
<mat-autocomplete
|
||||
#symbolAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
<ng-container *ngIf="!isLoading">
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItems"
|
||||
class="line-height-1"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span
|
||||
><b>{{ lookupItem.name }}</b></span
|
||||
>
|
||||
<br />
|
||||
<small class="text-muted"
|
||||
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
|
||||
}}<ng-container *ngIf="lookupItem.assetSubClass">
|
||||
· {{ lookupItem.assetSubClassString }}</ng-container
|
||||
></small
|
||||
>
|
||||
</mat-option>
|
||||
</ng-container>
|
||||
</mat-autocomplete>
|
||||
<mat-spinner
|
||||
*ngIf="isLoading"
|
||||
class="position-absolute"
|
||||
[diameter]="20"
|
||||
></mat-spinner>
|
@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-mdc-progress-spinner {
|
||||
right: 0;
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormControl, NgControl, Validators } from '@angular/forms';
|
||||
import {
|
||||
MatAutocomplete,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatFormFieldControl } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { isString } from 'lodash';
|
||||
import { Subject, tap } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
switchMap
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { AbstractMatFormField } from './abstract-mat-form-field';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[attr.aria-describedBy]': 'describedBy',
|
||||
'[id]': 'id'
|
||||
},
|
||||
selector: 'gf-symbol-autocomplete',
|
||||
styleUrls: ['./symbol-autocomplete.component.scss'],
|
||||
templateUrl: 'symbol-autocomplete.component.html',
|
||||
providers: [
|
||||
{
|
||||
provide: MatFormFieldControl,
|
||||
useExisting: SymbolAutocompleteComponent
|
||||
}
|
||||
]
|
||||
})
|
||||
export class SymbolAutocompleteComponent
|
||||
extends AbstractMatFormField<LookupItem>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
@Input() private includeIndices = false;
|
||||
@Input() public isLoading = false;
|
||||
|
||||
@ViewChild(MatInput, { static: false }) private input: MatInput;
|
||||
|
||||
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
|
||||
|
||||
public control = new FormControl();
|
||||
public filteredLookupItems: (LookupItem & { assetSubClassString: string })[] =
|
||||
[];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
public readonly _elementRef: ElementRef,
|
||||
public readonly _focusMonitor: FocusMonitor,
|
||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||
public readonly dataService: DataService,
|
||||
public readonly ngControl: NgControl
|
||||
) {
|
||||
super(_elementRef, _focusMonitor, ngControl);
|
||||
|
||||
this.controlType = 'symbol-autocomplete';
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
super.required = this.ngControl.control?.hasValidator(Validators.required);
|
||||
|
||||
if (this.disabled) {
|
||||
this.control.disable();
|
||||
}
|
||||
|
||||
this.control.valueChanges
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
filter((query) => {
|
||||
return isString(query) && query.length > 1;
|
||||
}),
|
||||
tap(() => {
|
||||
this.isLoading = true;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}),
|
||||
switchMap((query: string) => {
|
||||
return this.dataService.fetchSymbols({
|
||||
query,
|
||||
includeIndices: this.includeIndices
|
||||
});
|
||||
})
|
||||
)
|
||||
.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems.map((lookupItem) => {
|
||||
return {
|
||||
...lookupItem,
|
||||
assetSubClassString: translate(lookupItem.assetSubClass)
|
||||
};
|
||||
});
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public displayFn(aLookupItem: LookupItem) {
|
||||
return aLookupItem?.symbol ?? '';
|
||||
}
|
||||
|
||||
public get empty() {
|
||||
return this.input?.empty;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
public isValueInOptions(value: string) {
|
||||
return this.filteredLookupItems.some((item) => {
|
||||
return item.symbol === value;
|
||||
});
|
||||
}
|
||||
|
||||
public ngDoCheck() {
|
||||
if (this.ngControl) {
|
||||
this.validateRequired();
|
||||
|
||||
if (this.control.touched) {
|
||||
this.validateSelection();
|
||||
}
|
||||
|
||||
this.errorState = this.ngControl.invalid && this.ngControl.touched;
|
||||
this.stateChanges.next();
|
||||
}
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
super.value = {
|
||||
dataSource: event.option.value.dataSource,
|
||||
symbol: event.option.value.symbol
|
||||
} as LookupItem;
|
||||
}
|
||||
|
||||
public set value(value: LookupItem) {
|
||||
this.control.setValue(value);
|
||||
super.value = value;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private validateRequired() {
|
||||
const requiredCheck = super.required
|
||||
? !super.value?.dataSource || !super.value?.symbol
|
||||
: false;
|
||||
if (requiredCheck) {
|
||||
this.ngControl.control.setErrors({ invalidData: true });
|
||||
}
|
||||
}
|
||||
|
||||
private validateSelection() {
|
||||
const error =
|
||||
!this.isValueInOptions(this.input?.value) ||
|
||||
this.input?.value !== super.value?.symbol;
|
||||
if (error) {
|
||||
this.ngControl.control.setErrors({ invalidData: true });
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [SymbolAutocompleteComponent],
|
||||
exports: [SymbolAutocompleteComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolModule,
|
||||
MatAutocompleteModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfSymbolAutocompleteModule {}
|
17
package.json
17
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.280.0",
|
||||
"version": "1.285.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -64,11 +64,11 @@
|
||||
"@angular/router": "15.2.5",
|
||||
"@angular/service-worker": "15.2.5",
|
||||
"@codewithdan/observable-store": "2.2.15",
|
||||
"@dfinity/agent": "0.15.1",
|
||||
"@dfinity/auth-client": "0.15.1",
|
||||
"@dfinity/candid": "0.15.1",
|
||||
"@dfinity/identity": "0.15.1",
|
||||
"@dfinity/principal": "0.15.1",
|
||||
"@dfinity/agent": "0.15.7",
|
||||
"@dfinity/auth-client": "0.15.7",
|
||||
"@dfinity/candid": "0.15.7",
|
||||
"@dfinity/identity": "0.15.7",
|
||||
"@dfinity/principal": "0.15.7",
|
||||
"@dinero.js/currencies": "2.0.0-alpha.8",
|
||||
"@nestjs/bull": "0.6.3",
|
||||
"@nestjs/common": "9.1.4",
|
||||
@ -79,7 +79,7 @@
|
||||
"@nestjs/platform-express": "9.1.4",
|
||||
"@nestjs/schedule": "2.1.0",
|
||||
"@nestjs/serve-static": "3.0.0",
|
||||
"@prisma/client": "4.14.1",
|
||||
"@prisma/client": "4.15.0",
|
||||
"@simplewebauthn/browser": "5.2.1",
|
||||
"@simplewebauthn/server": "5.2.1",
|
||||
"@stripe/stripe-js": "1.47.0",
|
||||
@ -105,6 +105,7 @@
|
||||
"date-fns": "2.29.3",
|
||||
"envalid": "7.3.1",
|
||||
"google-spreadsheet": "3.2.0",
|
||||
"helmet": "7.0.0",
|
||||
"http-status-codes": "2.2.0",
|
||||
"ionicons": "7.1.0",
|
||||
"lodash": "4.17.21",
|
||||
@ -119,7 +120,7 @@
|
||||
"passport": "0.6.0",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "4.14.1",
|
||||
"prisma": "4.15.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rxjs": "7.5.6",
|
||||
"stripe": "11.12.0",
|
||||
|
@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = []
|
||||
binaryTargets = ["debian-openssl-1.1.x", "linux-arm64-openssl-1.1.x", "native"]
|
||||
binaryTargets = ["debian-openssl-1.1.x", "linux-arm64-openssl-3.0.x", "native"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
Reference in New Issue
Block a user