Compare commits
48 Commits
Author | SHA1 | Date | |
---|---|---|---|
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 | |||
60b2115e3b | |||
e7956943ba | |||
f66edf8de0 | |||
29028a81f5 | |||
c9878c9050 | |||
73ac4b4197 | |||
016634a77f |
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
.nx/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
|
@ -1,2 +1,3 @@
|
||||
/.nx/cache
|
||||
/dist
|
||||
/test/import
|
||||
|
86
CHANGELOG.md
86
CHANGELOG.md
@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 2.17.0 - 2023-11-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to edit the exchange rates in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the biometric authentication
|
||||
- Fixed the alignment of the icons in various menus
|
||||
|
||||
## 2.16.0 - 2023-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||
- Improved the usability and validation in the cash balance transfer from one to another account
|
||||
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
||||
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
||||
- Improved the date parsing in the import historical market data of the admin control panel
|
||||
- Improved the localized meta data (keywords) in `html` files
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prisma` from version `5.4.2` to `5.5.2`
|
||||
|
||||
## 2.15.0 - 2023-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style and wording of the position detail dialog
|
||||
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
|
||||
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
|
||||
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
|
||||
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
|
||||
- Upgraded `uuid` from version `9.0.0` to `9.0.1`
|
||||
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the chart in the account detail dialog for accounts excluded from analysis
|
||||
- Verified the current benchmark before loading it on the analysis page
|
||||
|
||||
## 2.14.0 - 2023-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
|
||||
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the fees on account level feature from experimental to general availability
|
||||
- Moved the interest on account level feature from experimental to general availability
|
||||
- Moved the search for a holding from experimental to general availability
|
||||
- Improved the error message in the activities import for `csv` files
|
||||
- Removed the application version from the client
|
||||
- Allowed to edit today’s historical market data in the asset profile details dialog of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the style of the active page in the header navigation
|
||||
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
|
||||
|
||||
## 2.13.0 - 2023-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added a chart to the account detail dialog
|
||||
- Added an `i18n` service to query `messages.*.xlf` files on the server
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the users table in the admin control panel to an `@angular/material` data table
|
||||
- Improved the styling of the membership status
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where holdings were requested twice from the server
|
||||
|
||||
## 2.12.0 - 2023-10-17
|
||||
|
||||
### Added
|
||||
|
@ -1,5 +1,17 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Experimental Features
|
||||
|
||||
New functionality can be enabled using a feature flag switch from the user settings.
|
||||
|
||||
### Backend
|
||||
|
||||
Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
@ -190,36 +190,46 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const currentAccountIds = accountsOfUser.map(({ id }) => {
|
||||
return id;
|
||||
const accountFrom = accountsOfUser.find(({ id }) => {
|
||||
return id === accountIdFrom;
|
||||
});
|
||||
|
||||
if (
|
||||
![accountIdFrom, accountIdTo].every((accountId) => {
|
||||
return currentAccountIds.includes(accountId);
|
||||
})
|
||||
) {
|
||||
const accountTo = accountsOfUser.find(({ id }) => {
|
||||
return id === accountIdTo;
|
||||
});
|
||||
|
||||
if (!accountFrom || !accountTo) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const { currency } = accountsOfUser.find(({ id }) => {
|
||||
return id === accountIdFrom;
|
||||
});
|
||||
if (accountFrom.id === accountTo.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (accountFrom.balance < balance) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
currency,
|
||||
accountId: accountIdFrom,
|
||||
accountId: accountFrom.id,
|
||||
amount: -balance,
|
||||
currency: accountFrom.currency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
currency,
|
||||
accountId: accountIdTo,
|
||||
accountId: accountTo.id,
|
||||
amount: balance,
|
||||
currency: accountFrom.currency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class TransferBalanceDto {
|
||||
@IsString()
|
||||
@ -8,5 +8,6 @@ export class TransferBalanceDto {
|
||||
accountIdTo: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
balance: number;
|
||||
}
|
||||
|
@ -7,7 +7,10 @@ import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
getAssetProfileIdentifier,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -331,9 +334,9 @@ export class AdminController {
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
date,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: resetHours(parseISO(date)),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
|
@ -23,7 +23,13 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
Property,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@ -94,9 +100,17 @@ export class AdminService {
|
||||
return currency !== DEFAULT_CURRENCY;
|
||||
})
|
||||
.map((currency) => {
|
||||
const label1 = DEFAULT_CURRENCY;
|
||||
const label2 = currency;
|
||||
|
||||
return {
|
||||
label1: DEFAULT_CURRENCY,
|
||||
label2: currency,
|
||||
label1,
|
||||
label2,
|
||||
dataSource:
|
||||
DataSource[
|
||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||
],
|
||||
symbol: `${label1}${label2}`,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
DEFAULT_CURRENCY,
|
||||
@ -303,15 +317,21 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
|
@ -1,11 +1,23 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { IsDate, IsNumber, IsOptional } from 'class-validator';
|
||||
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateMarketDataDto {
|
||||
@IsDate()
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
date?: string;
|
||||
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
|
@ -83,6 +83,7 @@ export class ImportService {
|
||||
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameDay(activity.date, parseDate(dateString)) &&
|
||||
@ -280,6 +281,9 @@ export class ImportService {
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -350,6 +354,9 @@ export class ImportService {
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -476,6 +483,7 @@ export class ImportService {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, date) &&
|
||||
@ -509,6 +517,9 @@ export class ImportService {
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
figi: null,
|
||||
figiComposite: null,
|
||||
figiShareClass: null,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
|
@ -13,7 +13,9 @@ import {
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
@ -48,9 +50,11 @@ export class CreateOrderDto {
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
fee: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
@ -64,6 +68,7 @@ export class CreateOrderDto {
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
unitPrice: number;
|
||||
|
||||
@IsBoolean()
|
||||
|
@ -13,7 +13,9 @@ import {
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
@ -47,12 +49,14 @@ export class UpdateOrderDto {
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
@ -66,5 +70,6 @@ export class UpdateOrderDto {
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
unitPrice: number;
|
||||
}
|
||||
|
@ -323,7 +323,8 @@ export class PortfolioController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
@ -335,6 +336,7 @@ export class PortfolioController {
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
|
@ -372,20 +372,23 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1110,12 +1113,14 @@ export class PortfolioService {
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
@ -1124,7 +1129,8 @@ export class PortfolioService {
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1174,7 +1180,8 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||
@ -1763,7 +1770,7 @@ export class PortfolioService {
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
@ -1851,7 +1858,7 @@ export class PortfolioService {
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
|
@ -104,7 +104,7 @@ export class SubscriptionController {
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -164,10 +164,10 @@ export class UserService {
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessAssistant
|
||||
);
|
||||
// currentPermissions = without(
|
||||
// currentPermissions,
|
||||
// permissions.xyz
|
||||
// );
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
|
@ -58,6 +58,14 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -94,6 +102,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -166,6 +178,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -312,6 +328,14 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -348,6 +372,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -420,6 +448,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -595,7 +627,15 @@
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
@ -630,6 +670,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -702,6 +746,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -722,6 +770,14 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -758,6 +814,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -830,6 +890,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -2,6 +2,7 @@ import * as fs from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
DEFAULT_ROOT_URL,
|
||||
@ -11,22 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
const descriptions = {
|
||||
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
|
||||
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||
tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
|
||||
};
|
||||
|
||||
const title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
const titleShort = 'Ghostfolio';
|
||||
const i18nService = new I18nService();
|
||||
|
||||
let indexHtmlMap: { [languageCode: string]: string } = {};
|
||||
|
||||
const title = 'Ghostfolio';
|
||||
|
||||
try {
|
||||
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
|
||||
(map, languageCode) => ({
|
||||
@ -43,47 +34,47 @@ try {
|
||||
const locales = {
|
||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
|
||||
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
|
||||
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
|
||||
},
|
||||
'/en/blog/2022/08/500-stars-on-github': {
|
||||
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
|
||||
title: `500 Stars - ${titleShort}`
|
||||
title: `500 Stars - ${title}`
|
||||
},
|
||||
'/en/blog/2022/10/hacktoberfest-2022': {
|
||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
|
||||
title: `Hacktoberfest 2022 - ${titleShort}`
|
||||
title: `Hacktoberfest 2022 - ${title}`
|
||||
},
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
|
||||
featureGraphicPath: 'assets/images/blog/20221226.jpg',
|
||||
title: `The importance of tracking your personal finances - ${titleShort}`
|
||||
title: `The importance of tracking your personal finances - ${title}`
|
||||
},
|
||||
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
|
||||
title: `Ghostfolio meets Umbrel - ${titleShort}`
|
||||
title: `Ghostfolio meets Umbrel - ${title}`
|
||||
},
|
||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
|
||||
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
|
||||
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}`
|
||||
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`
|
||||
},
|
||||
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
|
||||
featureGraphicPath: 'assets/images/blog/20230520.jpg',
|
||||
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
|
||||
title: `Unlock your Financial Potential with Ghostfolio - ${title}`
|
||||
},
|
||||
'/en/blog/2023/07/exploring-the-path-to-fire': {
|
||||
featureGraphicPath: 'assets/images/blog/20230701.jpg',
|
||||
title: `Exploring the Path to FIRE - ${titleShort}`
|
||||
title: `Exploring the Path to FIRE - ${title}`
|
||||
},
|
||||
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
|
||||
title: `Ghostfolio joins OSS Friends - ${titleShort}`
|
||||
title: `Ghostfolio joins OSS Friends - ${title}`
|
||||
},
|
||||
'/en/blog/2023/09/ghostfolio-2': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
||||
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
||||
title: `Announcing Ghostfolio 2.0 - ${title}`
|
||||
},
|
||||
'/en/blog/2023/09/hacktoberfest-2023': {
|
||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||
title: `Hacktoberfest 2023 - ${titleShort}`
|
||||
title: `Hacktoberfest 2023 - ${title}`
|
||||
}
|
||||
};
|
||||
|
||||
@ -130,10 +121,22 @@ export const HtmlTemplateMiddleware = async (
|
||||
languageCode,
|
||||
path,
|
||||
rootUrl,
|
||||
description: descriptions[languageCode],
|
||||
description: i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'metaDescription'
|
||||
}),
|
||||
featureGraphicPath:
|
||||
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
|
||||
title: locales[path]?.title ?? title
|
||||
keywords: i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'metaKeywords'
|
||||
}),
|
||||
title:
|
||||
locales[path]?.title ??
|
||||
`${title} – ${i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'slogan'
|
||||
})}`
|
||||
});
|
||||
|
||||
return response.send(indexHtml);
|
||||
|
@ -38,6 +38,7 @@ export class ConfigurationService {
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
OPEN_FIGI_API_KEY: str({ default: '' }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
|
@ -164,6 +164,9 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -178,6 +181,9 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -189,6 +195,9 @@ export class DataGatheringService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
|
@ -105,9 +105,11 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return DataSource.ALPHA_VANTAGE;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -134,13 +134,15 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return DataSource.COINGECKO;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
if (symbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
@ -151,7 +153,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const response = await got(
|
||||
`${this.URL}/simple/price?ids=${aSymbols.join(
|
||||
`${this.URL}/simple/price?ids=${symbols.join(
|
||||
','
|
||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||
{
|
||||
@ -162,7 +164,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
|
||||
for (const symbol in response) {
|
||||
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
||||
results[symbol] = {
|
||||
response[symbol] = {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: DataSource.COINGECKO,
|
||||
@ -175,7 +177,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return results;
|
||||
return response;
|
||||
}
|
||||
|
||||
public getTestSymbol() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
|
||||
@Module({
|
||||
exports: [
|
||||
DataEnhancerService,
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
'DataEnhancers'
|
||||
@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
|
||||
imports: [ConfigurationModule, CryptocurrencyModule],
|
||||
providers: [
|
||||
DataEnhancerService,
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
{
|
||||
inject: [
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
|
||||
useFactory: (openfigi, trackinsight, yahooFinance) => [
|
||||
openfigi,
|
||||
trackinsight,
|
||||
yahooFinance
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@ -0,0 +1,85 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { parseSymbol } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import got, { Headers } from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://api.openfigi.com';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public async enhance({
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
response: Partial<SymbolProfile>;
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
if (
|
||||
!(
|
||||
response.assetClass === 'EQUITY' &&
|
||||
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
|
||||
)
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers: Headers = {};
|
||||
const { exchange, ticker } = parseSymbol({
|
||||
symbol,
|
||||
dataSource: response.dataSource
|
||||
});
|
||||
|
||||
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
|
||||
headers['X-OPENFIGI-APIKEY'] =
|
||||
this.configurationService.get('OPEN_FIGI_API_KEY');
|
||||
}
|
||||
|
||||
let abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const mappings = await got
|
||||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||
headers,
|
||||
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
})
|
||||
.json<any[]>();
|
||||
|
||||
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
|
||||
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
|
||||
|
||||
if (figi) {
|
||||
response.figi = figi;
|
||||
}
|
||||
|
||||
if (compositeFIGI) {
|
||||
response.figiComposite = compositeFIGI;
|
||||
}
|
||||
|
||||
if (shareClassFIGI) {
|
||||
response.figiShareClass = shareClassFIGI;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'OPENFIGI';
|
||||
}
|
||||
|
||||
public getTestSymbol() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -311,7 +311,9 @@ export class DataProviderService {
|
||||
i + maximumNumberOfSymbolsPerRequest
|
||||
);
|
||||
|
||||
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
|
||||
const promise = Promise.resolve(
|
||||
dataProvider.getQuotes({ symbols: symbolsChunk })
|
||||
);
|
||||
|
||||
promises.push(
|
||||
promise.then(async (result) => {
|
||||
|
@ -131,17 +131,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return DataSource.EOD_HISTORICAL_DATA;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const symbols = aSymbols.map((symbol) => {
|
||||
return this.convertToEodSymbol(symbol);
|
||||
});
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
let response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (symbols.length <= 0) {
|
||||
return {};
|
||||
return response;
|
||||
}
|
||||
|
||||
const eodHistoricalDataSymbols = symbols.map((symbol) => {
|
||||
return this.convertToEodSymbol(symbol);
|
||||
});
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
@ -150,9 +154,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const realTimeResponse = await got(
|
||||
`${this.URL}/real-time/${symbols[0]}?api_token=${
|
||||
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&s=${symbols.join(',')}`,
|
||||
}&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
@ -160,10 +164,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
).json<any>();
|
||||
|
||||
const quotes =
|
||||
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
||||
eodHistoricalDataSymbols.length === 1
|
||||
? [realTimeResponse]
|
||||
: realTimeResponse;
|
||||
|
||||
const searchResponse = await Promise.all(
|
||||
symbols
|
||||
eodHistoricalDataSymbols
|
||||
.filter((symbol) => {
|
||||
return !symbol.endsWith('.FOREX');
|
||||
})
|
||||
@ -176,7 +182,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return items[0];
|
||||
});
|
||||
|
||||
const response = quotes.reduce(
|
||||
response = quotes.reduce(
|
||||
(
|
||||
result: { [symbol: string]: IDataProviderResponse },
|
||||
{ close, code, timestamp }
|
||||
|
@ -113,13 +113,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
return DataSource.FINANCIAL_MODELING_PREP;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
if (symbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
@ -130,7 +132,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const response = await got(
|
||||
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
|
||||
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
@ -138,7 +140,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
).json<any>();
|
||||
|
||||
for (const { price, symbol } of response) {
|
||||
results[symbol] = {
|
||||
response[symbol] = {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: DataSource.FINANCIAL_MODELING_PREP,
|
||||
@ -150,7 +152,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
Logger.error(error, 'FinancialModelingPrepService');
|
||||
}
|
||||
|
||||
return results;
|
||||
return response;
|
||||
}
|
||||
|
||||
public getTestSymbol() {
|
||||
|
@ -99,18 +99,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return DataSource.GOOGLE_SHEETS;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (symbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols.map((symbol) => {
|
||||
symbols.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
@ -129,7 +131,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
const marketPrice = parseFloat(row['marketPrice']);
|
||||
const symbol = row['symbol'];
|
||||
|
||||
if (aSymbols.includes(symbol)) {
|
||||
if (symbols.includes(symbol)) {
|
||||
response[symbol] = {
|
||||
marketPrice,
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
|
@ -36,9 +36,11 @@ export interface DataProviderInterface {
|
||||
|
||||
getName(): DataSource;
|
||||
|
||||
getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||
getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||
|
||||
getTestSymbol(): string;
|
||||
|
||||
|
@ -133,18 +133,20 @@ export class ManualService implements DataProviderInterface {
|
||||
return DataSource.MANUAL;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
if (symbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols.map((symbol) => {
|
||||
symbols.map((symbol) => {
|
||||
return { symbol, dataSource: this.getName() };
|
||||
})
|
||||
);
|
||||
@ -154,10 +156,10 @@ export class ManualService implements DataProviderInterface {
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
take: aSymbols.length,
|
||||
take: symbols.length,
|
||||
where: {
|
||||
symbol: {
|
||||
in: aSymbols
|
||||
in: symbols
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -87,15 +87,17 @@ export class RapidApiService implements DataProviderInterface {
|
||||
return DataSource.RAPID_API;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (symbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const symbol = aSymbols[0];
|
||||
const symbol = symbols[0];
|
||||
|
||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||
const fgi = await this.getFearAndGreedIndex();
|
||||
|
@ -156,20 +156,22 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return DataSource.YAHOO;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
public async getQuotes({
|
||||
symbols
|
||||
}: {
|
||||
symbols: string[];
|
||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (symbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
||||
const yahooFinanceSymbols = symbols.map((symbol) =>
|
||||
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
|
||||
);
|
||||
|
||||
try {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
let quotes: Pick<
|
||||
Quote,
|
||||
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
|
||||
|
67
apps/api/src/services/i18n/i18n.service.ts
Normal file
67
apps/api/src/services/i18n/i18n.service.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export class I18nService {
|
||||
private localesPath = join(__dirname, 'assets', 'locales');
|
||||
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
|
||||
|
||||
public constructor() {
|
||||
this.loadFiles();
|
||||
}
|
||||
|
||||
public getTranslation({
|
||||
id,
|
||||
languageCode
|
||||
}: {
|
||||
id: string;
|
||||
languageCode: string;
|
||||
}): string {
|
||||
const $ = this.translations[languageCode];
|
||||
|
||||
if (!$) {
|
||||
Logger.warn(`Translation not found for locale '${languageCode}'`);
|
||||
}
|
||||
|
||||
const translatedText = $(
|
||||
`trans-unit[id="${id}"] > ${
|
||||
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
|
||||
}`
|
||||
).text();
|
||||
|
||||
if (!translatedText) {
|
||||
Logger.warn(
|
||||
`Translation not found for id '${id}' in locale '${languageCode}'`
|
||||
);
|
||||
}
|
||||
|
||||
return translatedText.trim();
|
||||
}
|
||||
|
||||
private loadFiles() {
|
||||
try {
|
||||
const files = readdirSync(this.localesPath, 'utf-8');
|
||||
|
||||
for (const file of files) {
|
||||
const xmlData = readFileSync(join(this.localesPath, file), 'utf8');
|
||||
this.translations[this.parseLanguageCode(file)] =
|
||||
this.parseXml(xmlData);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'I18nService');
|
||||
}
|
||||
}
|
||||
|
||||
private parseLanguageCode(aFileName: string) {
|
||||
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/);
|
||||
|
||||
return match ? match[1] : DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
|
||||
private parseXml(xmlData: string): cheerio.CheerioAPI {
|
||||
return cheerio.load(xmlData, { xmlMode: true });
|
||||
}
|
||||
}
|
@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
OPEN_FIGI_API_KEY: string;
|
||||
PORT: number;
|
||||
RAPID_API_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
|
@ -86,14 +86,24 @@ export class SymbolProfileService {
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: { comment, scraperConfiguration, symbolMapping },
|
||||
data: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
},
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
|
@ -73,6 +73,11 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'i18n',
|
||||
loadChildren: () =>
|
||||
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
|
||||
},
|
||||
{
|
||||
path: paths.markets,
|
||||
loadChildren: () =>
|
||||
|
@ -165,7 +165,6 @@
|
||||
<div class="row text-center">
|
||||
<div class="col">
|
||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
{{ version }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { DataService } from './services/data.service';
|
||||
import { TokenStorageService } from './services/token-storage.service';
|
||||
import { UserService } from './services/user/user.service';
|
||||
@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public routerLinkResources = ['/' + $localize`resources`];
|
||||
public showFooter = false;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
|
@ -3,5 +3,9 @@
|
||||
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.chart-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import Big from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public balance: number;
|
||||
public currency: string;
|
||||
public equity: number;
|
||||
public hasImpersonationId: boolean;
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public isLoadingChart: boolean;
|
||||
public name: string;
|
||||
public orders: OrderWithAccount[];
|
||||
public platformName: string;
|
||||
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.userService.stateChanged
|
||||
@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
public ngOnInit() {
|
||||
this.isLoadingChart = true;
|
||||
|
||||
this.dataService
|
||||
.fetchAccount(this.data.accountId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -101,9 +107,46 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({
|
||||
filters: [
|
||||
{
|
||||
id: this.data.accountId,
|
||||
type: 'ACCOUNT'
|
||||
}
|
||||
],
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
this.historicalDataItems = chart.map(
|
||||
({ date, value, valueInPercentage }) => {
|
||||
return {
|
||||
date,
|
||||
value:
|
||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||
? valueInPercentage
|
||||
: value
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.isLoadingChart = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((impersonationId) => {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
}
|
||||
|
||||
public onClose(): void {
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container mb-3">
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isLoading]="isLoadingChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
></gf-investment-chart>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
||||
GfActivitiesTableModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfInvestmentChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
|
@ -2,6 +2,7 @@
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-stroked-button
|
||||
[disabled]="dataSource?.data.length < 2"
|
||||
(click)="onTransferBalance()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
|
||||
@ -253,16 +254,20 @@
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.isDefault || element.transactionCount > 0"
|
||||
(click)="onDeleteAccount(element.id)"
|
||||
>
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -9,7 +9,11 @@
|
||||
[showYAxis]="true"
|
||||
[symbol]="symbol"
|
||||
></gf-line-chart>
|
||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||
<div
|
||||
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
|
||||
class="d-flex"
|
||||
[hidden]="!marketData.length > 0"
|
||||
>
|
||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||
<div
|
||||
|
@ -28,7 +28,6 @@
|
||||
|
||||
&.today {
|
||||
background-color: rgba(var(--palette-accent-500), 1);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
public ngOnChanges() {
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
||||
this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value: marketDataItem.marketPrice
|
||||
date: format(date, DATE_FORMAT),
|
||||
value: marketPrice
|
||||
};
|
||||
});
|
||||
|
||||
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
const date = parseISO(`${yearMonth}-${day}`);
|
||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||
|
||||
if (isSameDay(date, new Date())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: <MarketDataDetailDialogParams>{
|
||||
date,
|
||||
|
@ -57,10 +57,16 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
|
||||
public onUpdate() {
|
||||
this.adminService
|
||||
.putMarketData({
|
||||
.postMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
marketData: { marketPrice: this.data.marketPrice },
|
||||
marketData: {
|
||||
marketData: [
|
||||
{
|
||||
date: this.data.date.toISOString(),
|
||||
marketPrice: this.data.marketPrice
|
||||
}
|
||||
]
|
||||
},
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -143,12 +143,24 @@
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activitiesCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -6,19 +6,24 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
MarketData,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -33,14 +38,23 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetClass: string;
|
||||
public assetProfileClass: string;
|
||||
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
return { id: assetClass, label: translate(assetClass) };
|
||||
});
|
||||
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
|
||||
return { id: assetSubClass, label: translate(assetSubClass) };
|
||||
});
|
||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
assetClass: new FormControl<AssetClass>(undefined),
|
||||
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
||||
comment: '',
|
||||
name: ['', Validators.required],
|
||||
scraperConfiguration: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
public assetProfileSubClass: string;
|
||||
public benchmarks: Partial<SymbolProfile>[];
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
@ -86,8 +100,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.subscribe(({ assetProfile, marketData }) => {
|
||||
this.assetProfile = assetProfile;
|
||||
|
||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
||||
return id === this.assetProfile.id;
|
||||
@ -114,7 +128,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
assetClass: this.assetProfile.assetClass ?? null,
|
||||
assetSubClass: this.assetProfile.assetSubClass ?? null,
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
name: this.assetProfile.name ?? this.assetProfile.symbol,
|
||||
scraperConfiguration: JSON.stringify(
|
||||
this.assetProfile?.scraperConfiguration ?? {}
|
||||
),
|
||||
@ -157,7 +174,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
dataSource: this.data.dataSource,
|
||||
marketData: {
|
||||
marketData: marketData.map(({ date, marketPrice }) => {
|
||||
return { marketPrice, date: parseISO(date) };
|
||||
return { marketPrice, date: parseDate(date).toISOString() };
|
||||
})
|
||||
},
|
||||
symbol: this.data.symbol
|
||||
@ -204,9 +221,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
||||
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
||||
name: this.assetProfileForm.controls['name'].value,
|
||||
scraperConfiguration,
|
||||
symbolMapping,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||
symbolMapping
|
||||
};
|
||||
|
||||
this.adminService
|
||||
|
@ -112,7 +112,11 @@
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfileClass"
|
||||
[value]="assetProfileClass"
|
||||
>Asset Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -120,8 +124,8 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetSubClass"
|
||||
[value]="assetSubClass"
|
||||
[hidden]="!assetProfileSubClass"
|
||||
[value]="assetProfileSubClass"
|
||||
>Asset Sub Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -174,6 +178,38 @@
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name</mat-label>
|
||||
<input formControlName="name" matInput type="text" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Asset Class</mat-label>
|
||||
<mat-select formControlName="assetClass">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetClass of assetClasses"
|
||||
[value]="assetClass.id"
|
||||
>{{ assetClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Asset Sub Class</mat-label>
|
||||
<mat-select formControlName="assetSubClass">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetSubClass of assetSubClasses"
|
||||
[value]="assetSubClass.id"
|
||||
>{{ assetSubClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50">
|
||||
<mat-checkbox
|
||||
|
@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -26,6 +27,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
MatDialogModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TextFieldModule
|
||||
],
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
@ -170,20 +169,20 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatCheckboxChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
value: aEvent.checked ? true : undefined
|
||||
});
|
||||
}
|
||||
|
||||
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) {
|
||||
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
value: aEvent.checked ? undefined : false
|
||||
});
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
value: aEvent.checked ? true : undefined
|
||||
});
|
||||
}
|
||||
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt($localize`Please set your system message:`);
|
||||
|
||||
|
@ -55,6 +55,18 @@
|
||||
</td>
|
||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||
<td>
|
||||
<a
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[queryParams]="{
|
||||
assetProfileDialog: true,
|
||||
dataSource: exchangeRate.dataSource,
|
||||
symbol: exchangeRate.symbol
|
||||
}"
|
||||
[routerLink]="['/admin', 'market-data']"
|
||||
>
|
||||
<ion-icon name="create-outline"></ion-icon>
|
||||
</a>
|
||||
<button
|
||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
@ -81,21 +93,23 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Signup</div>
|
||||
<div class="w-50">
|
||||
<mat-checkbox
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||
(change)="onEnableUserSignupModeChange($event)"
|
||||
></mat-checkbox>
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-checkbox
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-checkbox>
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
|
@ -3,8 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -18,10 +19,11 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatCardModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [CacheService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -86,12 +86,16 @@
|
||||
</button>
|
||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -66,12 +66,16 @@
|
||||
</button>
|
||||
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
@ -20,13 +21,15 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-users.html'
|
||||
})
|
||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
public dataSource: MatTableDataSource<AdminData['users'][0]> =
|
||||
new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public displayedColumns: string[] = [];
|
||||
public getEmojiFlag = getEmojiFlag;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToImpersonateAllUsers: boolean;
|
||||
public info: InfoItem;
|
||||
public user: User;
|
||||
public users: AdminData['users'];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -44,6 +47,29 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
if (this.hasPermissionForSubscription) {
|
||||
this.displayedColumns = [
|
||||
'index',
|
||||
'user',
|
||||
'country',
|
||||
'registration',
|
||||
'accounts',
|
||||
'activities',
|
||||
'engagementPerDay',
|
||||
'lastRequest',
|
||||
'actions'
|
||||
];
|
||||
} else {
|
||||
this.displayedColumns = [
|
||||
'index',
|
||||
'user',
|
||||
'registration',
|
||||
'accounts',
|
||||
'activities',
|
||||
'actions'
|
||||
];
|
||||
}
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -118,7 +144,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ users }) => {
|
||||
this.users = users;
|
||||
this.dataSource = new MatTableDataSource(users);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
@ -2,136 +2,236 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="users">
|
||||
<table class="gf-table">
|
||||
<thead>
|
||||
<tr class="mat-mdc-header-row">
|
||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">#</th>
|
||||
<th class="mat-mdc-header-cell px-1 py-2" i18n>User</th>
|
||||
<th
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
>
|
||||
<ng-container i18n>Country</ng-container>
|
||||
</th>
|
||||
<th class="mat-mdc-header-cell px-1 py-2">
|
||||
<ng-container i18n>Registration</ng-container>
|
||||
</th>
|
||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">
|
||||
<ng-container i18n>Accounts</ng-container>
|
||||
</th>
|
||||
<th class="mat-mdc-header-cell px-1 py-2 text-right">
|
||||
<ng-container i18n>Activities</ng-container>
|
||||
</th>
|
||||
<th
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||
>
|
||||
<ng-container i18n>Engagement per Day</ng-container>
|
||||
</th>
|
||||
<th
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
i18n
|
||||
>
|
||||
Last Request
|
||||
</th>
|
||||
<th class="mat-mdc-header-cell px-1 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let userItem of users; let i = index"
|
||||
class="mat-mdc-row"
|
||||
<table class="gf-table" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="index">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||
mat-header-cell
|
||||
>
|
||||
<td class="mat-mdc-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-none d-sm-inline-block text-monospace"
|
||||
>{{ userItem.id }}</span
|
||||
>
|
||||
<span class="d-inline-block d-sm-none text-monospace"
|
||||
>{{ (userItem.id | slice:0:5) + '...' }}</span
|
||||
>
|
||||
<gf-premium-indicator
|
||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
[enableLink]="false"
|
||||
[title]="'Expires ' + formatDistanceToNow(userItem.subscription.expiresAt) + ' (' + (userItem.subscription.expiresAt | date: defaultDateFormat) + ')'"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
>
|
||||
<span class="h5" [title]="userItem.country"
|
||||
>{{ getEmojiFlag(userItem.country) }}</span
|
||||
#
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element; let i=index"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
mat-cell
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="user">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
i18n
|
||||
mat-header-cell
|
||||
>
|
||||
User
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-none d-sm-inline-block text-monospace"
|
||||
>{{ element.id }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2 text-right">
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userItem.accountCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2 text-right">
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userItem.transactionCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
<span class="d-inline-block d-sm-none text-monospace"
|
||||
>{{ (element.id | slice:0:5) + '...' }}</span
|
||||
>
|
||||
<gf-premium-indicator
|
||||
*ngIf="element?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
[enableLink]="false"
|
||||
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
matColumnDef="country"
|
||||
>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Country</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
<span class="h5" [title]="element.country"
|
||||
>{{ getEmojiFlag(element.country) }}</span
|
||||
>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userItem.engagement"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="registration">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Registration</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
{{ formatDistanceToNow(element.createdAt) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="accounts">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Accounts</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
mat-cell
|
||||
>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="element.accountCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="activities">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Activities</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
mat-cell
|
||||
>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="element.transactionCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
matColumnDef="engagementPerDay"
|
||||
>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2 text-right"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Engagement per Day</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
mat-cell
|
||||
>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="element.engagement"
|
||||
></gf-value>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
matColumnDef="lastRequest"
|
||||
>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
i18n
|
||||
mat-header-cell
|
||||
>
|
||||
Last Request
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
{{ formatDistanceToNow(element.lastActivity) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
mat-header-cell
|
||||
></th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
{{ formatDistanceToNow(userItem.lastActivity) }}
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
*ngIf="hasPermissionToImpersonateAllUsers"
|
||||
mat-menu-item
|
||||
(click)="onImpersonateUser(element.id)"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
*ngIf="hasPermissionToImpersonateAllUsers"
|
||||
mat-menu-item
|
||||
(click)="onImpersonateUser(userItem.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
|
||||
<span i18n>Impersonate User</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="userItem.id === user?.id"
|
||||
(click)="onDeleteUser(userItem.id)"
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.id === user?.id"
|
||||
(click)="onDeleteUser(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete User</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr
|
||||
*matHeaderRowDef="displayedColumns"
|
||||
class="mat-mdc-header-row"
|
||||
mat-header-row
|
||||
></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="mat-mdc-row"
|
||||
mat-row
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
|
||||
GfPremiumIndicatorModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule
|
||||
MatMenuModule,
|
||||
MatTableModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -1,6 +1,5 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -1,7 +1,9 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
padding: 0 !important;
|
||||
|
||||
@media (min-width: 576px) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
@ -81,8 +81,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
.subscribe((impersonationId) => {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="container justify-content-center p-3">
|
||||
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
|
||||
<div class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="positions === undefined"
|
||||
|
@ -155,16 +155,18 @@
|
||||
[isDate]="true"
|
||||
[locale]="data.locale"
|
||||
[value]="firstBuyDate"
|
||||
>First Buy Date</gf-value
|
||||
>First Activity</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="transactionCount"
|
||||
>Transactions</gf-value
|
||||
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
|
||||
><ng-container *ngIf="transactionCount !== 1"
|
||||
>Activities</ng-container
|
||||
></gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
|
@ -2,48 +2,42 @@
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="d-flex">
|
||||
<div class="mx-auto">
|
||||
<div class="align-items-center d-flex mb-1">
|
||||
<a [routerLink]="routerLinkPricing"
|
||||
>{{ user?.subscription?.type }}</a
|
||||
>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
<div *ngIf="user?.subscription?.type === 'Premium'">
|
||||
<ng-container i18n>Valid until</ng-container> {{
|
||||
user?.subscription?.expiresAt | date: defaultDateFormat }}
|
||||
</div>
|
||||
<div *ngIf="user?.subscription?.type === 'Basic'">
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||
>
|
||||
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||
>Upgrade</ng-container
|
||||
>
|
||||
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||
>Renew</ng-container
|
||||
>
|
||||
</button>
|
||||
<div *ngIf="price" class="mt-1">
|
||||
<ng-container *ngIf="coupon"
|
||||
><del class="text-muted"
|
||||
>{{ baseCurrency }} {{ price }}</del
|
||||
> {{ baseCurrency }} {{ price - coupon
|
||||
}}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="!coupon"
|
||||
>{{ baseCurrency }} {{ price }}</ng-container
|
||||
> <span i18n>per year</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex flex-column">
|
||||
<gf-membership-card
|
||||
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
|
||||
[name]="user?.subscription?.type"
|
||||
></gf-membership-card>
|
||||
<div
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="d-flex flex-column mt-5"
|
||||
>
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||
>
|
||||
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||
>Upgrade</ng-container
|
||||
>
|
||||
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||
>Renew</ng-container
|
||||
>
|
||||
</button>
|
||||
<div *ngIf="price" class="mt-1 text-center">
|
||||
<ng-container *ngIf="coupon"
|
||||
><del class="text-muted"
|
||||
>{{ baseCurrency }} {{ price }}</del
|
||||
> {{ baseCurrency }} {{ price - coupon
|
||||
}}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="!coupon"
|
||||
>{{ baseCurrency }} {{ price }}</ng-container
|
||||
> <span i18n>per year</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex justfiy-content-center mt-4">
|
||||
<a
|
||||
*ngIf="!user?.subscription?.expiresAt"
|
||||
class="mr-2 my-2"
|
||||
class="mx-1"
|
||||
mat-stroked-button
|
||||
[href]="trySubscriptionMail"
|
||||
><span i18n>Try Premium</span>
|
||||
@ -54,7 +48,7 @@
|
||||
></a>
|
||||
<a
|
||||
*ngIf="hasPermissionToUpdateUserSettings"
|
||||
class="mr-2 my-2"
|
||||
class="mx-1"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]=""
|
||||
|
@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfMembershipCardModule } from '@ghostfolio/ui/membership-card';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -13,6 +14,7 @@ import { UserAccountMembershipComponent } from './user-account-membership.compon
|
||||
exports: [UserAccountMembershipComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfMembershipCardModule,
|
||||
GfPremiumIndicatorModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-4 text-center">
|
||||
<h1 class="h3 line-height-1 mb-4 text-center">
|
||||
<span class="d-none d-sm-block"
|
||||
><ng-container i18n>Our</ng-container> OSS Friends</span
|
||||
>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
@ -20,7 +19,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
public routerLinkFaq = ['/' + $localize`faq`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
|
@ -35,9 +35,6 @@
|
||||
title="Contributors to Ghostfolio"
|
||||
>contributors</a
|
||||
>.
|
||||
<ng-container *ngIf="version">
|
||||
This instance is running Ghostfolio {{ version }}.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasPermissionForSubscription"
|
||||
>Check the system status at
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
||||
|
@ -13,8 +13,8 @@ import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { EMPTY, Subject, Subscription } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
|
||||
@ -283,7 +283,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
data: {
|
||||
accounts: this.accounts
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
@ -301,7 +300,14 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
accountIdTo,
|
||||
balance
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
alert($localize`Oops, cash balance transfer has failed.`);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.fetchAccounts();
|
||||
});
|
||||
|
@ -15,6 +15,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { map, startWith } from 'rxjs/operators';
|
||||
@ -30,7 +31,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
||||
})
|
||||
export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
public accountForm: FormGroup;
|
||||
public currencies: string[] = [];
|
||||
public currencies: Currency[] = [];
|
||||
public filteredPlatforms: Observable<Platform[]>;
|
||||
public platforms: Platform[];
|
||||
|
||||
@ -46,7 +47,10 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
public ngOnInit() {
|
||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||
|
||||
this.currencies = currencies;
|
||||
this.currencies = currencies.map((currency) => ({
|
||||
label: currency,
|
||||
value: currency
|
||||
}));
|
||||
this.platforms = platforms;
|
||||
|
||||
this.accountForm = this.formBuilder.group({
|
||||
@ -101,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
const account: CreateAccountDto | UpdateAccountDto = {
|
||||
balance: this.accountForm.controls['balance'].value,
|
||||
comment: this.accountForm.controls['comment'].value,
|
||||
currency: this.accountForm.controls['currency'].value,
|
||||
currency: this.accountForm.controls['currency'].value?.value,
|
||||
id: this.accountForm.controls['accountId'].value,
|
||||
isExcluded: this.accountForm.controls['isExcluded'].value,
|
||||
name: this.accountForm.controls['name'].value,
|
||||
|
@ -20,11 +20,10 @@
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<mat-select formControlName="currency">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency"
|
||||
>{{ currency }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
<gf-currency-selector
|
||||
formControlName="currency"
|
||||
[currencies]="currencies"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
@ -37,7 +36,7 @@
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
/>
|
||||
<span class="ml-2" matTextSuffix
|
||||
>{{ accountForm.controls['currency'].value }}</span
|
||||
>{{ accountForm.controls['currency']?.value?.value }}</span
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -7,8 +7,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
|
||||
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
|
||||
|
||||
@ -17,6 +17,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfCurrencySelectorModule,
|
||||
GfSymbolIconModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
@ -24,7 +25,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
]
|
||||
})
|
||||
|
@ -4,7 +4,13 @@ import {
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
|
||||
import { Account } from '@prisma/client';
|
||||
@ -35,11 +41,16 @@ export class TransferBalanceDialog implements OnDestroy {
|
||||
public ngOnInit() {
|
||||
this.accounts = this.data.accounts;
|
||||
|
||||
this.transferBalanceForm = this.formBuilder.group({
|
||||
balance: [0, Validators.required],
|
||||
fromAccount: ['', Validators.required],
|
||||
toAccount: ['', Validators.required]
|
||||
});
|
||||
this.transferBalanceForm = this.formBuilder.group(
|
||||
{
|
||||
balance: ['', Validators.required],
|
||||
fromAccount: ['', Validators.required],
|
||||
toAccount: ['', Validators.required]
|
||||
},
|
||||
{
|
||||
validators: this.compareAccounts
|
||||
}
|
||||
);
|
||||
|
||||
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
|
||||
this.currency = this.accounts.find((account) => {
|
||||
@ -66,4 +77,13 @@ export class TransferBalanceDialog implements OnDestroy {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private compareAccounts(control: AbstractControl): ValidationErrors {
|
||||
const accountFrom = control.get('fromAccount');
|
||||
const accountTo = control.get('toAccount');
|
||||
|
||||
if (accountFrom.value === accountTo.value) {
|
||||
return { invalid: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
19
apps/client/src/app/pages/i18n/i18n-page-routing.module.ts
Normal file
19
apps/client/src/app/pages/i18n/i18n-page-routing.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { I18nPageComponent } from './i18n-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: I18nPageComponent,
|
||||
path: ''
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class I18nPageRoutingModule {}
|
21
apps/client/src/app/pages/i18n/i18n-page.component.ts
Normal file
21
apps/client/src/app/pages/i18n/i18n-page.component.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-i18n-page',
|
||||
styleUrls: ['./i18n-page.scss'],
|
||||
templateUrl: './i18n-page.html'
|
||||
})
|
||||
export class I18nPageComponent implements OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
15
apps/client/src/app/pages/i18n/i18n-page.html
Normal file
15
apps/client/src/app/pages/i18n/i18n-page.html
Normal file
@ -0,0 +1,15 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<ul>
|
||||
<li i18n="@@metaDescription">
|
||||
Ghostfolio is a personal finance dashboard to keep track of your assets
|
||||
like stocks, ETFs or cryptocurrencies across multiple platforms.
|
||||
</li>
|
||||
<li i18n="@@metaKeywords">
|
||||
app, asset, cryptocurrency, dashboard, etf, finance, management,
|
||||
performance, portfolio, software, stock, trading, wealth, web3
|
||||
</li>
|
||||
<li i18n="@@slogan">Open Source Wealth Management Software</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
12
apps/client/src/app/pages/i18n/i18n-page.module.ts
Normal file
12
apps/client/src/app/pages/i18n/i18n-page.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
|
||||
import { I18nPageRoutingModule } from './i18n-page-routing.module';
|
||||
import { I18nPageComponent } from './i18n-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [I18nPageComponent],
|
||||
imports: [CommonModule, I18nPageRoutingModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class I18nPageModule {}
|
3
apps/client/src/app/pages/i18n/i18n-page.scss
Normal file
3
apps/client/src/app/pages/i18n/i18n-page.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -21,10 +21,7 @@
|
||||
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small
|
||||
>
|
||||
</mat-option>
|
||||
<mat-option
|
||||
*ngIf="data.user?.settings?.isExperimentalFeatures"
|
||||
value="FEE"
|
||||
>
|
||||
<mat-option value="FEE">
|
||||
<span><b>{{ typesTranslationMap['FEE'] }}</b></span>
|
||||
<small class="d-block line-height-1 text-muted text-nowrap" i18n
|
||||
>One-time fee, annual account fees</small
|
||||
@ -36,10 +33,7 @@
|
||||
>Distribution of corporate earnings</small
|
||||
>
|
||||
</mat-option>
|
||||
<mat-option
|
||||
*ngIf="data.user?.settings?.isExperimentalFeatures"
|
||||
value="INTEREST"
|
||||
>
|
||||
<mat-option value="INTEREST">
|
||||
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span>
|
||||
<small class="d-block line-height-1 text-muted text-nowrap" i18n
|
||||
>Revenue for lending out money</small
|
||||
|
@ -267,6 +267,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
const content = fileContent.split('\n').slice(1);
|
||||
|
||||
try {
|
||||
const data = await this.importActivitiesService.importCsv({
|
||||
fileContent,
|
||||
@ -277,7 +279,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: error?.activities ?? [],
|
||||
activities: error?.activities ?? content,
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
}
|
||||
|
@ -309,7 +309,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
this.isLoadingInvestmentChart = true;
|
||||
|
||||
this.dataService
|
||||
@ -385,35 +384,37 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private updateBenchmarkDataItems() {
|
||||
this.benchmarkDataItems = [];
|
||||
|
||||
if (this.user.settings.benchmark) {
|
||||
const { dataSource, symbol } =
|
||||
this.benchmarks.find(({ id }) => {
|
||||
return id === this.user.settings.benchmark;
|
||||
}) ?? {};
|
||||
|
||||
this.dataService
|
||||
.fetchBenchmarkBySymbol({
|
||||
dataSource,
|
||||
symbol,
|
||||
startDate: this.firstOrderDate
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.benchmarkDataItems = marketData.map(({ date, value }) => {
|
||||
return {
|
||||
date,
|
||||
value
|
||||
};
|
||||
if (dataSource && symbol) {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
|
||||
this.dataService
|
||||
.fetchBenchmarkBySymbol({
|
||||
dataSource,
|
||||
symbol,
|
||||
startDate: this.firstOrderDate
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.benchmarkDataItems = marketData.map(({ date, value }) => {
|
||||
return {
|
||||
date,
|
||||
value
|
||||
};
|
||||
});
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.benchmarkDataItems = [];
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
<div class="container">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
|
||||
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
|
||||
<div class="my-4 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
@ -23,7 +23,7 @@
|
||||
[benchmarks]="benchmarks"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performanceDataItems]="performanceDataItemsInPercentage"
|
||||
[user]="user"
|
||||
@ -149,7 +149,7 @@
|
||||
[daysInMarket]="daysInMarket"
|
||||
[historicalDataItems]="performanceDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingInvestmentChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
[range]="user?.settings?.dateRange"
|
||||
></gf-investment-chart>
|
||||
|
@ -35,15 +35,19 @@
|
||||
its capabilities, security, and user experience.
|
||||
</p>
|
||||
<p i18n>
|
||||
Let’s dive deeper into the detailed comparison table below to gain a
|
||||
thorough understanding of how Ghostfolio positions itself relative
|
||||
to {{ product2.name }}. We will explore various aspects such as
|
||||
features, data privacy, pricing, and more, allowing you to make a
|
||||
well-informed choice for your personal requirements.
|
||||
Let’s dive deeper into the detailed Ghostfolio vs {{ product2.name
|
||||
}} comparison table below to gain a thorough understanding of how
|
||||
Ghostfolio positions itself relative to {{ product2.name }}. We will
|
||||
explore various aspects such as features, data privacy, pricing, and
|
||||
more, allowing you to make a well-informed choice for your personal
|
||||
requirements.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<table class="gf-table w-100">
|
||||
<caption class="text-center" i18n>
|
||||
Ghostfolio vs {{ product2.name }} comparison table
|
||||
</caption>
|
||||
<thead>
|
||||
<tr class="mat-mdc-header-row">
|
||||
<th class="mat-mdc-header-cell px-1 py-2"></th>
|
||||
@ -197,12 +201,13 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p i18n>
|
||||
Please note that the information provided is based on our
|
||||
independent research and analysis. This website is not affiliated
|
||||
with {{ product2.name }} or any other product mentioned in the
|
||||
comparison. As the landscape of personal finance tools evolves, it
|
||||
is essential to verify any specific details or changes directly from
|
||||
the respective product page. Data needs a refresh? Help us maintain
|
||||
Please note that the information provided in the Ghostfolio vs {{
|
||||
product2.name }} comparison table is based on our independent
|
||||
research and analysis. This website is not affiliated with {{
|
||||
product2.name }} or any other product mentioned in the comparison.
|
||||
As the landscape of personal finance tools evolves, it is essential
|
||||
to verify any specific details or changes directly from the
|
||||
respective product page. Data needs a refresh? Help us maintain
|
||||
accurate data on
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
@ -291,7 +296,7 @@
|
||||
aria-current="page"
|
||||
class="active breadcrumb-item text-truncate"
|
||||
>
|
||||
{{ product2.name }}
|
||||
Ghostfolio vs {{ product2.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Product } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { AltooPageComponent } from './products/altoo-page.component';
|
||||
import { BeanvestPageComponent } from './products/beanvest-page.component';
|
||||
import { CapitallyPageComponent } from './products/capitally-page.component';
|
||||
import { CapMonPageComponent } from './products/capmon-page.component';
|
||||
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
|
||||
import { DeltaPageComponent } from './products/delta-page.component';
|
||||
@ -10,6 +12,7 @@ import { FinaryPageComponent } from './products/finary-page.component';
|
||||
import { FolisharePageComponent } from './products/folishare-page.component';
|
||||
import { GetquinPageComponent } from './products/getquin-page.component';
|
||||
import { GoSpatzPageComponent } from './products/gospatz-page.component';
|
||||
import { IntuitMintPageComponent } from './products/intuit-mint-page.component';
|
||||
import { JustEtfPageComponent } from './products/justetf-page.component';
|
||||
import { KuberaPageComponent } from './products/kubera-page.component';
|
||||
import { MarketsShPageComponent } from './products/markets.sh-page.component';
|
||||
@ -28,6 +31,7 @@ import { StocklePageComponent } from './products/stockle-page.component';
|
||||
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
|
||||
import { SumioPageComponent } from './products/sumio-page.component';
|
||||
import { UtlunaPageComponent } from './products/utluna-page.component';
|
||||
import { WealthicaPageComponent } from './products/wealthica-page.component';
|
||||
import { YeekateePageComponent } from './products/yeekatee-page.component';
|
||||
|
||||
export const products: Product[] = [
|
||||
@ -63,13 +67,34 @@ export const products: Product[] = [
|
||||
origin: $localize`Switzerland`,
|
||||
slogan: 'Simplicity for Complex Wealth'
|
||||
},
|
||||
{
|
||||
component: BeanvestPageComponent,
|
||||
founded: 2020,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'beanvest',
|
||||
name: 'Beanvest',
|
||||
origin: $localize`France`,
|
||||
pricingPerYear: '$100',
|
||||
slogan: 'Stock Portfolio Tracker for Smart Investors'
|
||||
},
|
||||
{
|
||||
component: CapitallyPageComponent,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'capitally',
|
||||
name: 'Capitally',
|
||||
origin: $localize`Poland`,
|
||||
pricingPerYear: '€50',
|
||||
slogan: 'Optimize your investments performance'
|
||||
},
|
||||
{
|
||||
component: CapMonPageComponent,
|
||||
founded: 2022,
|
||||
key: 'capmon',
|
||||
name: 'CapMon.org',
|
||||
origin: $localize`Germany`,
|
||||
note: 'Sunset in 2023',
|
||||
note: 'CapMon.org has discontinued in 2023',
|
||||
slogan: 'Next Generation Assets Tracking'
|
||||
},
|
||||
{
|
||||
@ -158,6 +183,17 @@ export const products: Product[] = [
|
||||
origin: $localize`Germany`,
|
||||
slogan: 'Volle Kontrolle über deine Investitionen'
|
||||
},
|
||||
{
|
||||
component: IntuitMintPageComponent,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'intuit-mint',
|
||||
name: 'Intuit Mint',
|
||||
note: 'Intuit Mint has discontinued in 2023',
|
||||
origin: $localize`United States`,
|
||||
pricingPerYear: '$60',
|
||||
slogan: 'Managing money, made simple'
|
||||
},
|
||||
{
|
||||
component: JustEtfPageComponent,
|
||||
founded: 2011,
|
||||
@ -200,7 +236,7 @@ export const products: Product[] = [
|
||||
key: 'maybe-finance',
|
||||
languages: ['English'],
|
||||
name: 'Maybe Finance',
|
||||
note: 'Sunset in 2023',
|
||||
note: 'Maybe Finance has discontinued in 2023',
|
||||
origin: $localize`United States`,
|
||||
pricingPerYear: '$145',
|
||||
region: $localize`United States`,
|
||||
@ -328,7 +364,7 @@ export const products: Product[] = [
|
||||
key: 'stockmarketeye',
|
||||
name: 'StockMarketEye',
|
||||
origin: $localize`France`,
|
||||
note: 'Sunset in 2023',
|
||||
note: 'StockMarketEye has discontinued in 2023',
|
||||
slogan: 'A Powerful Portfolio & Investment Tracking App'
|
||||
},
|
||||
{
|
||||
@ -353,6 +389,18 @@ export const products: Product[] = [
|
||||
slogan: 'Your Portfolio. Revealed.',
|
||||
useAnonymously: true
|
||||
},
|
||||
{
|
||||
component: WealthicaPageComponent,
|
||||
founded: 2015,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'wealthica',
|
||||
languages: ['English', 'Français'],
|
||||
name: 'Wealthica',
|
||||
origin: $localize`Canada`,
|
||||
pricingPerYear: '$50',
|
||||
slogan: 'See all your investments in one place'
|
||||
},
|
||||
{
|
||||
component: YeekateePageComponent,
|
||||
founded: 2021,
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-beanvest-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class BeanvestPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'beanvest';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-capitally-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class CapitallyPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'capitally';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-intuit-mint-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class IntuitMintPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'intuit-mint';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-wealthica-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class WealthicaPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'wealthica';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -2,7 +2,6 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
||||
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
||||
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
|
||||
@ -203,15 +202,25 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public patchAssetProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: UniqueAsset & UpdateAssetProfileDto) {
|
||||
return this.http.patch<EnhancedSymbolProfile>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
{ comment, scraperConfiguration, symbolMapping }
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -237,25 +246,6 @@ export class AdminService {
|
||||
return this.http.post<Tag>(`/api/v1/tag`, aTag);
|
||||
}
|
||||
|
||||
public putMarketData({
|
||||
dataSource,
|
||||
date,
|
||||
marketData,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
marketData: UpdateMarketDataDto;
|
||||
symbol: string;
|
||||
}) {
|
||||
const url = `/api/v1/admin/market-data/${dataSource}/${symbol}/${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}`;
|
||||
|
||||
return this.http.put<MarketData>(url, marketData);
|
||||
}
|
||||
|
||||
public putPlatform(aPlatform: UpdatePlatformDto) {
|
||||
return this.http.put<Platform>(
|
||||
`/api/v1/platform/${aPlatform.id}`,
|
||||
|
@ -386,14 +386,20 @@ export class DataService {
|
||||
|
||||
public fetchPortfolioPerformance({
|
||||
filters,
|
||||
range
|
||||
range,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
range: DateRange;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Observable<PortfolioPerformanceResponse> {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
params = params.append('range', range);
|
||||
|
||||
if (withExcludedAccounts) {
|
||||
params = params.append('withExcludedAccounts', withExcludedAccounts);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>(`/api/v2/portfolio/performance`, {
|
||||
params
|
||||
|
@ -286,7 +286,7 @@ export class ImportActivitiesService {
|
||||
|
||||
for (const key of ImportActivitiesService.QUANTITY_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
return Math.abs(item[key]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +372,7 @@ export class ImportActivitiesService {
|
||||
|
||||
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
return Math.abs(item[key]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,12 +46,10 @@ export class WebAuthnService {
|
||||
switchMap((attOps) => {
|
||||
return startRegistration(attOps);
|
||||
}),
|
||||
switchMap((attResp) => {
|
||||
switchMap((credential) => {
|
||||
return this.http.post<AuthDeviceDto>(
|
||||
`/api/v1/auth/webauthn/verify-attestation`,
|
||||
{
|
||||
credential: attResp
|
||||
}
|
||||
{ credential }
|
||||
);
|
||||
}),
|
||||
tap((authDevice) =>
|
||||
@ -65,6 +63,7 @@ export class WebAuthnService {
|
||||
|
||||
public deregister() {
|
||||
const deviceId = this.getDeviceId();
|
||||
|
||||
return this.http
|
||||
.delete<AuthDeviceDto>(`/api/v1/auth-device/${deviceId}`)
|
||||
.pipe(
|
||||
@ -82,20 +81,21 @@ export class WebAuthnService {
|
||||
|
||||
public login() {
|
||||
const deviceId = this.getDeviceId();
|
||||
|
||||
return this.http
|
||||
.post<PublicKeyCredentialRequestOptionsJSON>(
|
||||
`/api/v1/auth/webauthn/generate-assertion-options`,
|
||||
{ deviceId }
|
||||
)
|
||||
.pipe(
|
||||
switchMap((requestOptionsJSON) =>
|
||||
startAuthentication(requestOptionsJSON, true)
|
||||
),
|
||||
switchMap((assertionResponse) => {
|
||||
switchMap((requestOptionsJSON) => {
|
||||
return startAuthentication(requestOptionsJSON);
|
||||
}),
|
||||
switchMap((credential) => {
|
||||
return this.http.post<{ authToken: string }>(
|
||||
`/api/v1/auth/webauthn/verify-assertion`,
|
||||
{
|
||||
credential: assertionResponse,
|
||||
credential,
|
||||
deviceId
|
||||
}
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"createdAt": "2023-10-05T00:00:00.000Z",
|
||||
"createdAt": "2023-10-21T00:00:00.000Z",
|
||||
"data": [
|
||||
{
|
||||
"name": "BoxyHQ",
|
||||
@ -21,11 +21,21 @@
|
||||
"description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
|
||||
"href": "https://documenso.com"
|
||||
},
|
||||
{
|
||||
"name": "dyrector.io",
|
||||
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
|
||||
"href": "https://dyrector.io"
|
||||
},
|
||||
{
|
||||
"name": "Erxes",
|
||||
"description": "The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
|
||||
"href": "https://erxes.io"
|
||||
},
|
||||
{
|
||||
"name": "Firecamp",
|
||||
"description": "vscode for apis, open-source postman/insomnia alternative",
|
||||
"href": "https://firecamp.io"
|
||||
},
|
||||
{
|
||||
"name": "Formbricks",
|
||||
"description": "Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
|
||||
@ -46,6 +56,11 @@
|
||||
"description": "Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
|
||||
"href": "https://www.hanko.io"
|
||||
},
|
||||
{
|
||||
"name": "Hook0",
|
||||
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
|
||||
"href": "https://www.hook0.com/"
|
||||
},
|
||||
{
|
||||
"name": "HTMX",
|
||||
"description": "HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
|
||||
@ -86,11 +101,21 @@
|
||||
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.",
|
||||
"href": "https://rivet.gg"
|
||||
},
|
||||
{
|
||||
"name": "Shelf.nu",
|
||||
"description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.",
|
||||
"href": "https://www.shelf.nu/"
|
||||
},
|
||||
{
|
||||
"name": "Sniffnet",
|
||||
"description": "Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
|
||||
"href": "https://www.sniffnet.net"
|
||||
},
|
||||
{
|
||||
"name": "Spark.NET",
|
||||
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
|
||||
"href": "https://spark-framework.net"
|
||||
},
|
||||
{
|
||||
"name": "Tolgee",
|
||||
"description": "Software localization from A to Z made really easy.",
|
||||
@ -101,16 +126,16 @@
|
||||
"description": "Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
|
||||
"href": "https://trigger.dev"
|
||||
},
|
||||
{
|
||||
"name": "Typebot",
|
||||
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
|
||||
"href": "https://typebot.io"
|
||||
},
|
||||
{
|
||||
"name": "Twenty",
|
||||
"description": "A modern CRM offering the flexibility of open-source, advanced features and sleek design.",
|
||||
"href": "https://twenty.com"
|
||||
},
|
||||
{
|
||||
"name": "Typebot",
|
||||
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
|
||||
"href": "https://typebot.io"
|
||||
},
|
||||
{
|
||||
"name": "Webiny",
|
||||
"description": "Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
|
||||
@ -120,11 +145,6 @@
|
||||
"name": "Webstudio",
|
||||
"description": "Webstudio is an open source alternative to Webflow",
|
||||
"href": "https://webstudio.is"
|
||||
},
|
||||
{
|
||||
"name": "Spark.NET",
|
||||
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
|
||||
"href": "https://spark-framework.net"
|
||||
}
|
||||
],
|
||||
"source": "https://formbricks.com/api/oss-friends"
|
||||
|
@ -1,6 +1,5 @@
|
||||
export const environment = {
|
||||
lastPublish: '{BUILD_TIMESTAMP}',
|
||||
production: true,
|
||||
stripePublicKey: '',
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
stripePublicKey: ''
|
||||
};
|
||||
|
@ -5,8 +5,7 @@
|
||||
export const environment = {
|
||||
lastPublish: null,
|
||||
production: false,
|
||||
stripePublicKey: '',
|
||||
version: 'dev'
|
||||
stripePublicKey: ''
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -6,10 +6,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta content="yes" name="apple-mobile-web-app-capable" />
|
||||
<meta content="${description}" name="description" />
|
||||
<meta
|
||||
content="app, asset, cryptocurrency, dashboard, etf, finance, management, performance, portfolio, software, stock, trading, wealth, web3"
|
||||
name="keywords"
|
||||
/>
|
||||
<meta content="${keywords}" name="keywords" />
|
||||
<meta content="yes" name="mobile-web-app-capable" />
|
||||
<meta content="summary_large_image" name="twitter:card" />
|
||||
<meta
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user