Browse Source

Merge pull request #32 from dandevaud/main

Main
pull/5027/head
dandevaud 2 years ago
committed by GitHub
parent
commit
1fc96b4067
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .gitignore
  2. 1
      .prettierignore
  3. 45
      CHANGELOG.md
  4. 38
      apps/api/src/app/account/account.controller.ts
  5. 3
      apps/api/src/app/account/transfer-balance.dto.ts
  6. 7
      apps/api/src/app/admin/admin.controller.ts
  7. 6
      apps/api/src/app/admin/admin.service.ts
  8. 16
      apps/api/src/app/admin/update-asset-profile.dto.ts
  9. 6
      apps/api/src/app/admin/update-market-data.dto.ts
  10. 2
      apps/api/src/app/import/import.service.ts
  11. 7
      apps/api/src/app/order/create-order.dto.ts
  12. 7
      apps/api/src/app/order/update-order.dto.ts
  13. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  14. 21
      apps/api/src/app/portfolio/portfolio.service.ts
  15. 50
      apps/api/src/assets/sitemap.xml
  16. 38
      apps/api/src/middlewares/html-template.middleware.ts
  17. 12
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  18. 3
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  19. 5
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  20. 12
      apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts
  21. 14
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  22. 42
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  23. 42
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  24. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  25. 14
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  26. 10
      apps/client/src/app/components/admin-overview/admin-overview.html
  27. 4
      apps/client/src/app/components/admin-overview/admin-overview.module.ts
  28. 4
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  29. 4
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  30. 4
      apps/client/src/app/components/admin-users/admin-users.html
  31. 1
      apps/client/src/app/components/dialog-footer/dialog-footer.component.html
  32. 4
      apps/client/src/app/components/dialog-footer/dialog-footer.component.scss
  33. 2
      apps/client/src/app/components/home-holdings/home-holdings.html
  34. 8
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  35. 14
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  36. 10
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  37. 11
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  38. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts
  39. 28
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  40. 5
      apps/client/src/app/pages/i18n/i18n-page.html
  41. 11
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  42. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  43. 29
      apps/client/src/app/pages/resources/personal-finance-tools/product-page-template.html
  44. 36
      apps/client/src/app/pages/resources/personal-finance-tools/products.ts
  45. 31
      apps/client/src/app/pages/resources/personal-finance-tools/products/beanvest-page.component.ts
  46. 31
      apps/client/src/app/pages/resources/personal-finance-tools/products/capitally-page.component.ts
  47. 31
      apps/client/src/app/pages/resources/personal-finance-tools/products/wealthica-page.component.ts
  48. 32
      apps/client/src/app/services/admin.service.ts
  49. 8
      apps/client/src/app/services/data.service.ts
  50. 4
      apps/client/src/app/services/import-activities.service.ts
  51. 18
      apps/client/src/app/services/web-authn.service.ts
  52. 5
      apps/client/src/index.html
  53. 3016
      apps/client/src/locales/messages.de.xlf
  54. 3016
      apps/client/src/locales/messages.es.xlf
  55. 3018
      apps/client/src/locales/messages.fr.xlf
  56. 3018
      apps/client/src/locales/messages.it.xlf
  57. 3018
      apps/client/src/locales/messages.nl.xlf
  58. 3018
      apps/client/src/locales/messages.pt.xlf
  59. 3076
      apps/client/src/locales/messages.tr.xlf
  60. 3025
      apps/client/src/locales/messages.xlf
  61. 4
      libs/common/src/lib/interfaces/currency.interface.ts
  62. 24
      libs/ui/src/lib/activities-table/activities-table.component.html
  63. 21
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  64. 3
      libs/ui/src/lib/currency-selector/currency-selector.component.scss
  65. 167
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  66. 23
      libs/ui/src/lib/currency-selector/currency-selector.module.ts
  67. 0
      libs/ui/src/lib/shared/abstract-mat-form-field.ts
  68. 5
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  69. 36
      nx.json
  70. 39
      package.json
  71. 5255
      yarn.lock

1
.gitignore

@ -27,6 +27,7 @@
/.angular/cache /.angular/cache
.env .env
.env.prod .env.prod
.nx/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

1
.prettierignore

@ -1,2 +1,3 @@
/.nx/cache
/dist /dist
/test/import /test/import

45
CHANGELOG.md

@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### 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 ## 2.14.0 - 2023-10-21
### Added ### Added

38
apps/api/src/app/account/account.controller.ts

@ -190,36 +190,46 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
const currentAccountIds = accountsOfUser.map(({ id }) => { const accountFrom = accountsOfUser.find(({ id }) => {
return id; return id === accountIdFrom;
}); });
if ( const accountTo = accountsOfUser.find(({ id }) => {
![accountIdFrom, accountIdTo].every((accountId) => { return id === accountIdTo;
return currentAccountIds.includes(accountId); });
})
) { if (!accountFrom || !accountTo) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
); );
} }
const { currency } = accountsOfUser.find(({ id }) => { if (accountFrom.id === accountTo.id) {
return id === accountIdFrom; 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({ await this.accountService.updateAccountBalance({
currency, accountId: accountFrom.id,
accountId: accountIdFrom,
amount: -balance, amount: -balance,
currency: accountFrom.currency,
userId: this.request.user.id userId: this.request.user.id
}); });
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
currency, accountId: accountTo.id,
accountId: accountIdTo,
amount: balance, amount: balance,
currency: accountFrom.currency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

3
apps/api/src/app/account/transfer-balance.dto.ts

@ -1,4 +1,4 @@
import { IsNumber, IsString } from 'class-validator'; import { IsNumber, IsPositive, IsString } from 'class-validator';
export class TransferBalanceDto { export class TransferBalanceDto {
@IsString() @IsString()
@ -8,5 +8,6 @@ export class TransferBalanceDto {
accountIdTo: string; accountIdTo: string;
@IsNumber() @IsNumber()
@IsPositive()
balance: number; balance: number;
} }

7
apps/api/src/app/admin/admin.controller.ts

@ -7,7 +7,10 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -331,9 +334,9 @@ export class AdminController {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({ ({ date, marketPrice }) => ({
dataSource, dataSource,
date,
marketPrice, marketPrice,
symbol, symbol,
date: resetHours(parseISO(date)),
state: 'CLOSE' state: 'CLOSE'
}) })
); );

6
apps/api/src/app/admin/admin.service.ts

@ -303,15 +303,21 @@ export class AdminService {
} }
public async patchAssetProfileData({ public async patchAssetProfileData({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({ await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping

16
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,11 +1,23 @@
import { Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsString()
@IsOptional()
name?: string;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
scraperConfiguration?: Prisma.InputJsonObject; scraperConfiguration?: Prisma.InputJsonObject;

6
apps/api/src/app/admin/update-market-data.dto.ts

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

2
apps/api/src/app/import/import.service.ts

@ -83,6 +83,7 @@ export class ImportService {
const isDuplicate = orders.some((activity) => { const isDuplicate = orders.some((activity) => {
return ( return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) && isSameDay(activity.date, parseDate(dateString)) &&
@ -482,6 +483,7 @@ export class ImportService {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(<string>(<unknown>dateString));
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) && isSameDay(activity.date, date) &&

7
apps/api/src/app/order/create-order.dto.ts

@ -13,7 +13,9 @@ import {
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsPositive,
IsString,
Min
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -48,9 +50,11 @@ export class CreateOrderDto {
date: string; date: string;
@IsNumber() @IsNumber()
@Min(0)
fee: number; fee: number;
@IsNumber() @IsNumber()
@IsPositive()
quantity: number; quantity: number;
@IsString() @IsString()
@ -64,6 +68,7 @@ export class CreateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive()
unitPrice: number; unitPrice: number;
@IsBoolean() @IsBoolean()

7
apps/api/src/app/order/update-order.dto.ts

@ -13,7 +13,9 @@ import {
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsPositive,
IsString,
Min
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -47,12 +49,14 @@ export class UpdateOrderDto {
date: string; date: string;
@IsNumber() @IsNumber()
@Min(0)
fee: number; fee: number;
@IsString() @IsString()
id: string; id: string;
@IsNumber() @IsNumber()
@IsPositive()
quantity: number; quantity: number;
@IsString() @IsString()
@ -66,5 +70,6 @@ export class UpdateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive()
unitPrice: number; unitPrice: number;
} }

4
apps/api/src/app/portfolio/portfolio.controller.ts

@ -358,7 +358,8 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
@ -370,6 +371,7 @@ export class PortfolioController {
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts,
userId: this.request.user.id userId: this.request.user.id
}); });

21
apps/api/src/app/portfolio/portfolio.service.ts

@ -372,20 +372,23 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId,
withExcludedAccounts = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> { }): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
withExcludedAccounts
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1199,12 +1202,14 @@ export class PortfolioService {
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId, impersonationId,
userId userId,
withExcludedAccounts = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1213,7 +1218,8 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
withExcludedAccounts
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1263,7 +1269,8 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId,
withExcludedAccounts
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = historicalDataContainer.items.find((item) => {
@ -1860,7 +1867,7 @@ export class PortfolioService {
filters, filters,
includeDrafts = false, includeDrafts = false,
userId, userId,
withExcludedAccounts withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
@ -1948,7 +1955,7 @@ export class PortfolioService {
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];

50
apps/api/src/assets/sitemap.xml

@ -58,6 +58,14 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -166,6 +174,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -312,6 +324,14 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -420,6 +440,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -595,7 +619,15 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -702,6 +734,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -722,6 +758,14 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -830,6 +874,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

38
apps/api/src/middlewares/html-template.middleware.ts

@ -12,13 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
const title = 'Ghostfolio – Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
const i18nService = new I18nService(); const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {}; let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio';
try { try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({ (map, languageCode) => ({
@ -35,47 +34,47 @@ try {
const locales = { const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}` title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
}, },
'/en/blog/2022/08/500-stars-on-github': { '/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}` title: `500 Stars - ${title}`
}, },
'/en/blog/2022/10/hacktoberfest-2022': { '/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}` title: `Hacktoberfest 2022 - ${title}`
}, },
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg', featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}` title: `The importance of tracking your personal finances - ${title}`
}, },
'/en/blog/2023/02/ghostfolio-meets-umbrel': { '/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}` title: `Ghostfolio meets Umbrel - ${title}`
}, },
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}` title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`
}, },
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg', featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}` title: `Unlock your Financial Potential with Ghostfolio - ${title}`
}, },
'/en/blog/2023/07/exploring-the-path-to-fire': { '/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg', featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}` title: `Exploring the Path to FIRE - ${title}`
}, },
'/en/blog/2023/08/ghostfolio-joins-oss-friends': { '/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}` title: `Ghostfolio joins OSS Friends - ${title}`
}, },
'/en/blog/2023/09/ghostfolio-2': { '/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}` title: `Announcing Ghostfolio 2.0 - ${title}`
}, },
'/en/blog/2023/09/hacktoberfest-2023': { '/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}` title: `Hacktoberfest 2023 - ${title}`
} }
}; };
@ -128,7 +127,16 @@ export const HtmlTemplateMiddleware = async (
}), }),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title keywords: i18nService.getTranslation({
languageCode,
id: 'metaKeywords'
}),
title:
locales[path]?.title ??
`${title}${i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
}); });
return response.send(indexHtml); return response.send(indexHtml);

12
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -86,14 +86,24 @@ export class SymbolProfileService {
} }
public updateSymbolProfile({ public updateSymbolProfile({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { comment, scraperConfiguration, symbolMapping }, data: {
assetClass,
assetSubClass,
comment,
name,
scraperConfiguration,
symbolMapping
},
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
} }

3
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -116,7 +116,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
type: 'ACCOUNT' type: 'ACCOUNT'
} }
], ],
range: 'max' range: 'max',
withExcludedAccounts: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { .subscribe(({ chart }) => {

5
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -2,6 +2,7 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-stroked-button mat-stroked-button
[disabled]="dataSource?.data.length < 2"
(click)="onTransferBalance()" (click)="onTransferBalance()"
> >
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon> <ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
@ -253,16 +254,20 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

12
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts

@ -57,10 +57,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public onUpdate() { public onUpdate() {
this.adminService this.adminService
.putMarketData({ .postMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
date: this.data.date, marketData: {
marketData: { marketPrice: this.data.marketPrice }, marketData: [
{
date: this.data.date.toISOString(),
marketPrice: this.data.marketPrice
}
]
},
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

14
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -143,12 +143,24 @@
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Delete</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

42
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -6,19 +6,24 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client'; import {
import { format, parseISO } from 'date-fns'; AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -33,14 +38,23 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'] styleUrls: ['./asset-profile-dialog.component.scss']
}) })
export class AssetProfileDialog implements OnDestroy, OnInit { export class AssetProfileDialog implements OnDestroy, OnInit {
public assetClass: string; public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '', comment: '',
name: ['', Validators.required],
scraperConfiguration: '', scraperConfiguration: '',
symbolMapping: '' symbolMapping: ''
}); });
public assetSubClass: string; public assetProfileSubClass: string;
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
@ -86,8 +100,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.assetClass = translate(this.assetProfile?.assetClass); this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetSubClass = translate(this.assetProfile?.assetSubClass); this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => { this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id; return id === this.assetProfile.id;
@ -114,6 +128,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
this.assetProfileForm.setValue({ this.assetProfileForm.setValue({
name: this.assetProfile.name,
assetClass: this.assetProfile.assetClass,
assetSubClass: this.assetProfile.assetSubClass,
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
scraperConfiguration: JSON.stringify( scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {} this.assetProfile?.scraperConfiguration ?? {}
@ -157,7 +174,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
marketData: { marketData: {
marketData: marketData.map(({ date, marketPrice }) => { marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseISO(date) }; return { marketPrice, date: parseDate(date).toISOString() };
}) })
}, },
symbol: this.data.symbol symbol: this.data.symbol
@ -204,9 +221,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {} } catch {}
const assetProfileData: UpdateAssetProfileDto = { const assetProfileData: UpdateAssetProfileDto = {
assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
scraperConfiguration, scraperConfiguration,
symbolMapping, symbolMapping
comment: this.assetProfileForm.controls['comment'].value ?? null
}; };
this.adminService this.adminService

42
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -112,7 +112,11 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass" <gf-value
i18n
size="medium"
[hidden]="!assetProfileClass"
[value]="assetProfileClass"
>Asset Class</gf-value >Asset Class</gf-value
> >
</div> </div>
@ -120,8 +124,8 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[hidden]="!assetSubClass" [hidden]="!assetProfileSubClass"
[value]="assetSubClass" [value]="assetProfileSubClass"
>Asset Sub Class</gf-value >Asset Sub Class</gf-value
> >
</div> </div>
@ -174,6 +178,38 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass.id"
>{{ assetClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass.id"
>{{ assetSubClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-checkbox

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -26,6 +27,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
MatDialogModule, MatDialogModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSelectModule,
ReactiveFormsModule, ReactiveFormsModule,
TextFieldModule TextFieldModule
], ],

14
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -1,5 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -169,17 +169,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
} }
public onReadOnlyModeChange(aEvent: MatCheckboxChange) { public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE, key: PROPERTY_IS_USER_SIGNUP_ENABLED,
value: aEvent.checked ? true : undefined value: aEvent.checked ? undefined : false
}); });
} }
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) { public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED, key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? undefined : false value: aEvent.checked ? true : undefined
}); });
} }

10
apps/client/src/app/components/admin-overview/admin-overview.html

@ -81,21 +81,23 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div> <div class="w-50" i18n>User Signup</div>
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="info.globalPermissions.includes(permissions.createUserAccount)" [checked]="info.globalPermissions.includes(permissions.createUserAccount)"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3"> <div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div> <div class="w-50" i18n>Read-only Mode</div>
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="info?.isReadOnlyMode" [checked]="info?.isReadOnlyMode"
(change)="onReadOnlyModeChange($event)" (change)="onReadOnlyModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3"> <div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">

4
apps/client/src/app/components/admin-overview/admin-overview.module.ts

@ -3,8 +3,8 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -18,9 +18,9 @@ import { AdminOverviewComponent } from './admin-overview.component';
FormsModule, FormsModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatCardModule, MatCardModule,
MatSelectModule, MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule ReactiveFormsModule
], ],
providers: [CacheService], providers: [CacheService],

4
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -86,12 +86,16 @@
</button> </button>
<mat-menu #platformMenu="matMenu" xPosition="before"> <mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)"> <button mat-menu-item (click)="onUpdatePlatform(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button mat-menu-item (click)="onDeletePlatform(element.id)"> <button mat-menu-item (click)="onDeletePlatform(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

4
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -66,12 +66,16 @@
</button> </button>
<mat-menu #tagMenu="matMenu" xPosition="before"> <mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)"> <button mat-menu-item (click)="onUpdateTag(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button mat-menu-item (click)="onDeleteTag(element.id)"> <button mat-menu-item (click)="onDeleteTag(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

4
apps/client/src/app/components/admin-users/admin-users.html

@ -203,16 +203,20 @@
mat-menu-item mat-menu-item
(click)="onImpersonateUser(element.id)" (click)="onImpersonateUser(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="contract-outline"></ion-icon> <ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.id === user?.id" [disabled]="element.id === user?.id"
(click)="onDeleteUser(element.id)" (click)="onDeleteUser(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span> <span i18n>Delete User</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

1
apps/client/src/app/components/dialog-footer/dialog-footer.component.html

@ -1,6 +1,5 @@
<button <button
*ngIf="deviceType === 'mobile'" *ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >

4
apps/client/src/app/components/dialog-footer/dialog-footer.component.scss

@ -1,7 +1,9 @@
:host { :host {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
margin-bottom: 0;
min-height: 0; min-height: 0;
@media (min-width: 576px) {
padding: 0 !important; padding: 0 !important;
}
} }

2
apps/client/src/app/components/home-holdings/home-holdings.html

@ -1,5 +1,5 @@
<div class="container justify-content-center p-3"> <div class="container justify-content-center p-3">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center"> <div class="mb-3 text-center">
<gf-toggle <gf-toggle
[defaultValue]="user?.settings?.dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined" [isLoading]="positions === undefined"

8
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -172,16 +172,18 @@
[isDate]="true" [isDate]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="firstBuyDate" [value]="firstBuyDate"
>First Buy Date</gf-value >First Activity</gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"
>Transactions</gf-value ><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">

14
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -13,8 +13,8 @@ import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { EMPTY, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component'; import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component'; import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
@ -283,7 +283,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
data: { data: {
accounts: this.accounts accounts: this.accounts
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -301,7 +300,14 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
accountIdTo, accountIdTo,
balance balance
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
catchError(() => {
alert($localize`Oops, cash balance transfer has failed.`);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => { .subscribe(() => {
this.fetchAccounts(); this.fetchAccounts();
}); });

10
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -15,6 +15,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { map, startWith } from 'rxjs/operators'; import { map, startWith } from 'rxjs/operators';
@ -30,7 +31,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
}) })
export class CreateOrUpdateAccountDialog implements OnDestroy { export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup; public accountForm: FormGroup;
public currencies: string[] = []; public currencies: Currency[] = [];
public filteredPlatforms: Observable<Platform[]>; public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[]; public platforms: Platform[];
@ -46,7 +47,10 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
public ngOnInit() { public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.platforms = platforms; this.platforms = platforms;
this.accountForm = this.formBuilder.group({ this.accountForm = this.formBuilder.group({
@ -101,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
const account: CreateAccountDto | UpdateAccountDto = { const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value, balance: this.accountForm.controls['balance'].value,
comment: this.accountForm.controls['comment'].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, id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value, isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value, name: this.accountForm.controls['name'].value,

11
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -20,11 +20,10 @@
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label> <mat-label i18n>Currency</mat-label>
<mat-select formControlName="currency"> <gf-currency-selector
<mat-option *ngFor="let currency of currencies" [value]="currency" formControlName="currency"
>{{ currency }}</mat-option [currencies]="currencies"
> />
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
@ -37,7 +36,7 @@
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix <span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span >{{ accountForm.controls['currency']?.value?.value }}</span
> >
</mat-form-field> </mat-form-field>
</div> </div>

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts

@ -7,8 +7,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; 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'; import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@ -17,6 +17,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfCurrencySelectorModule,
GfSymbolIconModule, GfSymbolIconModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
@ -24,7 +25,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule,
ReactiveFormsModule ReactiveFormsModule
] ]
}) })

28
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -4,7 +4,13 @@ import {
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
@ -35,11 +41,16 @@ export class TransferBalanceDialog implements OnDestroy {
public ngOnInit() { public ngOnInit() {
this.accounts = this.data.accounts; this.accounts = this.data.accounts;
this.transferBalanceForm = this.formBuilder.group({ this.transferBalanceForm = this.formBuilder.group(
balance: [0, Validators.required], {
balance: ['', Validators.required],
fromAccount: ['', Validators.required], fromAccount: ['', Validators.required],
toAccount: ['', Validators.required] toAccount: ['', Validators.required]
}); },
{
validators: this.compareAccounts
}
);
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => { this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
this.currency = this.accounts.find((account) => { this.currency = this.accounts.find((account) => {
@ -66,4 +77,13 @@ export class TransferBalanceDialog implements OnDestroy {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); 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 };
}
}
} }

5
apps/client/src/app/pages/i18n/i18n-page.html

@ -5,6 +5,11 @@
Ghostfolio is a personal finance dashboard to keep track of your assets Ghostfolio is a personal finance dashboard to keep track of your assets
like stocks, ETFs or cryptocurrencies across multiple platforms. like stocks, ETFs or cryptocurrencies across multiple platforms.
</li> </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> </ul>
</div> </div>
</div> </div>

11
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -309,7 +309,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
} }
private update() { private update() {
this.isLoadingBenchmarkComparator = true;
this.isLoadingInvestmentChart = true; this.isLoadingInvestmentChart = true;
this.dataService this.dataService
@ -385,12 +384,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
} }
private updateBenchmarkDataItems() { private updateBenchmarkDataItems() {
this.benchmarkDataItems = [];
if (this.user.settings.benchmark) { if (this.user.settings.benchmark) {
const { dataSource, symbol } = const { dataSource, symbol } =
this.benchmarks.find(({ id }) => { this.benchmarks.find(({ id }) => {
return id === this.user.settings.benchmark; return id === this.user.settings.benchmark;
}) ?? {}; }) ?? {};
if (dataSource && symbol) {
this.isLoadingBenchmarkComparator = true;
this.dataService this.dataService
.fetchBenchmarkBySymbol({ .fetchBenchmarkBySymbol({
dataSource, dataSource,
@ -410,10 +414,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} else { }
this.benchmarkDataItems = [];
this.isLoadingBenchmarkComparator = false;
} }
} }
} }

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -1,9 +1,9 @@
<div class="container"> <div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1> <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 <gf-toggle
[defaultValue]="user?.settings?.dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingBenchmarkComparator" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[options]="dateRangeOptions" [options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"
></gf-toggle> ></gf-toggle>
@ -23,7 +23,7 @@
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[isLoading]="isLoadingBenchmarkComparator" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage" [performanceDataItems]="performanceDataItemsInPercentage"
[user]="user" [user]="user"
@ -149,7 +149,7 @@
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingBenchmarkComparator" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange" [range]="user?.settings?.dateRange"
></gf-investment-chart> ></gf-investment-chart>

29
apps/client/src/app/pages/resources/personal-finance-tools/product-page-template.html

@ -35,15 +35,19 @@
its capabilities, security, and user experience. its capabilities, security, and user experience.
</p> </p>
<p i18n> <p i18n>
Let’s dive deeper into the detailed comparison table below to gain a Let’s dive deeper into the detailed Ghostfolio vs {{ product2.name
thorough understanding of how Ghostfolio positions itself relative }} comparison table below to gain a thorough understanding of how
to {{ product2.name }}. We will explore various aspects such as Ghostfolio positions itself relative to {{ product2.name }}. We will
features, data privacy, pricing, and more, allowing you to make a explore various aspects such as features, data privacy, pricing, and
well-informed choice for your personal requirements. more, allowing you to make a well-informed choice for your personal
requirements.
</p> </p>
</section> </section>
<section class="mb-4"> <section class="mb-4">
<table class="gf-table w-100"> <table class="gf-table w-100">
<caption class="text-center" i18n>
Ghostfolio vs {{ product2.name }} comparison table
</caption>
<thead> <thead>
<tr class="mat-mdc-header-row"> <tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2"></th> <th class="mat-mdc-header-cell px-1 py-2"></th>
@ -197,12 +201,13 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<p i18n> <p i18n>
Please note that the information provided is based on our Please note that the information provided in the Ghostfolio vs {{
independent research and analysis. This website is not affiliated product2.name }} comparison table is based on our independent
with {{ product2.name }} or any other product mentioned in the research and analysis. This website is not affiliated with {{
comparison. As the landscape of personal finance tools evolves, it product2.name }} or any other product mentioned in the comparison.
is essential to verify any specific details or changes directly from As the landscape of personal finance tools evolves, it is essential
the respective product page. Data needs a refresh? Help us maintain to verify any specific details or changes directly from the
respective product page. Data needs a refresh? Help us maintain
accurate data on accurate data on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p> </p>
@ -291,7 +296,7 @@
aria-current="page" aria-current="page"
class="active breadcrumb-item text-truncate" class="active breadcrumb-item text-truncate"
> >
{{ product2.name }} Ghostfolio vs {{ product2.name }}
</li> </li>
</ol> </ol>
</nav> </nav>

36
apps/client/src/app/pages/resources/personal-finance-tools/products.ts

@ -1,6 +1,8 @@
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { AltooPageComponent } from './products/altoo-page.component'; 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 { CapMonPageComponent } from './products/capmon-page.component';
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component'; import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
import { DeltaPageComponent } from './products/delta-page.component'; import { DeltaPageComponent } from './products/delta-page.component';
@ -28,6 +30,7 @@ import { StocklePageComponent } from './products/stockle-page.component';
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component'; import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
import { SumioPageComponent } from './products/sumio-page.component'; import { SumioPageComponent } from './products/sumio-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component'; import { UtlunaPageComponent } from './products/utluna-page.component';
import { WealthicaPageComponent } from './products/wealthica-page.component';
import { YeekateePageComponent } from './products/yeekatee-page.component'; import { YeekateePageComponent } from './products/yeekatee-page.component';
export const products: Product[] = [ export const products: Product[] = [
@ -63,6 +66,27 @@ export const products: Product[] = [
origin: $localize`Switzerland`, origin: $localize`Switzerland`,
slogan: 'Simplicity for Complex Wealth' 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, component: CapMonPageComponent,
founded: 2022, founded: 2022,
@ -353,6 +377,18 @@ export const products: Product[] = [
slogan: 'Your Portfolio. Revealed.', slogan: 'Your Portfolio. Revealed.',
useAnonymously: true 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, component: YeekateePageComponent,
founded: 2021, founded: 2021,

31
apps/client/src/app/pages/resources/personal-finance-tools/products/beanvest-page.component.ts

@ -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'
];
}

31
apps/client/src/app/pages/resources/personal-finance-tools/products/capitally-page.component.ts

@ -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'
];
}

31
apps/client/src/app/pages/resources/personal-finance-tools/products/wealthica-page.component.ts

@ -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'
];
}

32
apps/client/src/app/services/admin.service.ts

@ -2,7 +2,6 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.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 { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
@ -203,15 +202,25 @@ export class AdminService {
} }
public patchAssetProfile({ public patchAssetProfile({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: UniqueAsset & UpdateAssetProfileDto) { }: UniqueAsset & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>( return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/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); 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) { public putPlatform(aPlatform: UpdatePlatformDto) {
return this.http.put<Platform>( return this.http.put<Platform>(
`/api/v1/platform/${aPlatform.id}`, `/api/v1/platform/${aPlatform.id}`,

8
apps/client/src/app/services/data.service.ts

@ -386,14 +386,20 @@ export class DataService {
public fetchPortfolioPerformance({ public fetchPortfolioPerformance({
filters, filters,
range range,
withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
range: DateRange; range: DateRange;
withExcludedAccounts?: boolean;
}): Observable<PortfolioPerformanceResponse> { }): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range); params = params.append('range', range);
if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts);
}
return this.http return this.http
.get<any>(`/api/v2/portfolio/performance`, { .get<any>(`/api/v2/portfolio/performance`, {
params params

4
apps/client/src/app/services/import-activities.service.ts

@ -286,7 +286,7 @@ export class ImportActivitiesService {
for (const key of ImportActivitiesService.QUANTITY_KEYS) { for (const key of ImportActivitiesService.QUANTITY_KEYS) {
if (isFinite(item[key])) { if (isFinite(item[key])) {
return item[key]; return Math.abs(item[key]);
} }
} }
@ -374,7 +374,7 @@ export class ImportActivitiesService {
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) { for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
if (isFinite(item[key])) { if (isFinite(item[key])) {
return item[key]; return Math.abs(item[key]);
} }
} }

18
apps/client/src/app/services/web-authn.service.ts

@ -46,12 +46,10 @@ export class WebAuthnService {
switchMap((attOps) => { switchMap((attOps) => {
return startRegistration(attOps); return startRegistration(attOps);
}), }),
switchMap((attResp) => { switchMap((credential) => {
return this.http.post<AuthDeviceDto>( return this.http.post<AuthDeviceDto>(
`/api/v1/auth/webauthn/verify-attestation`, `/api/v1/auth/webauthn/verify-attestation`,
{ { credential }
credential: attResp
}
); );
}), }),
tap((authDevice) => tap((authDevice) =>
@ -65,6 +63,7 @@ export class WebAuthnService {
public deregister() { public deregister() {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http return this.http
.delete<AuthDeviceDto>(`/api/v1/auth-device/${deviceId}`) .delete<AuthDeviceDto>(`/api/v1/auth-device/${deviceId}`)
.pipe( .pipe(
@ -82,20 +81,21 @@ export class WebAuthnService {
public login() { public login() {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http return this.http
.post<PublicKeyCredentialRequestOptionsJSON>( .post<PublicKeyCredentialRequestOptionsJSON>(
`/api/v1/auth/webauthn/generate-assertion-options`, `/api/v1/auth/webauthn/generate-assertion-options`,
{ deviceId } { deviceId }
) )
.pipe( .pipe(
switchMap((requestOptionsJSON) => switchMap((requestOptionsJSON) => {
startAuthentication(requestOptionsJSON, true) return startAuthentication(requestOptionsJSON);
), }),
switchMap((assertionResponse) => { switchMap((credential) => {
return this.http.post<{ authToken: string }>( return this.http.post<{ authToken: string }>(
`/api/v1/auth/webauthn/verify-assertion`, `/api/v1/auth/webauthn/verify-assertion`,
{ {
credential: assertionResponse, credential,
deviceId deviceId
} }
); );

5
apps/client/src/index.html

@ -6,10 +6,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta content="yes" name="apple-mobile-web-app-capable" /> <meta content="yes" name="apple-mobile-web-app-capable" />
<meta content="${description}" name="description" /> <meta content="${description}" name="description" />
<meta <meta content="${keywords}" name="keywords" />
content="app, asset, cryptocurrency, dashboard, etf, finance, management, performance, portfolio, software, stock, trading, wealth, web3"
name="keywords"
/>
<meta content="yes" name="mobile-web-app-capable" /> <meta content="yes" name="mobile-web-app-capable" />
<meta content="summary_large_image" name="twitter:card" /> <meta content="summary_large_image" name="twitter:card" />
<meta <meta

3016
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

3016
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

3018
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

3018
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

3018
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

3018
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

3076
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

3025
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

4
libs/common/src/lib/interfaces/currency.interface.ts

@ -0,0 +1,4 @@
export interface Currency {
label: string;
value: string;
}

24
libs/ui/src/lib/activities-table/activities-table.component.html

@ -30,8 +30,10 @@
[disabled]="dataSource.data.length === 0" [disabled]="dataSource.data.length === 0"
(click)="onImportDividends()" (click)="onImportDividends()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline"></ion-icon> <ion-icon class="mr-2" name="color-wand-outline"></ion-icon>
<ng-container i18n>Import Dividends</ng-container>... <ng-container i18n>Import Dividends</ng-container>...
</span>
</button> </button>
<button <button
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
@ -40,8 +42,10 @@
[disabled]="dataSource.data.length === 0" [disabled]="dataSource.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export Activities</span> <span i18n>Export Activities</span>
</span>
</button> </button>
<button <button
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
@ -50,16 +54,20 @@
[disabled]="!hasDrafts" [disabled]="!hasDrafts"
(click)="onExportDrafts()" (click)="onExportDrafts()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon> <ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span> <span i18n>Export Drafts as ICS</span>
</span>
</button> </button>
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
(click)="onDeleteAllActivities()" (click)="onDeleteAllActivities()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete all Activities</span> <span i18n>Delete all Activities</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</div> </div>
@ -440,8 +448,10 @@
mat-menu-item mat-menu-item
(click)="onImport()" (click)="onImport()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<ng-container i18n>Import Activities</ng-container>... <ng-container i18n>Import Activities</ng-container>...
</span>
</button> </button>
<button <button
*ngIf="hasPermissionToCreateActivity" *ngIf="hasPermissionToCreateActivity"
@ -449,8 +459,10 @@
[disabled]="dataSource.data.length === 0" [disabled]="dataSource.data.length === 0"
(click)="onImportDividends()" (click)="onImportDividends()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline"></ion-icon> <ion-icon class="mr-2" name="color-wand-outline"></ion-icon>
<ng-container i18n>Import Dividends</ng-container>... <ng-container i18n>Import Dividends</ng-container>...
</span>
</button> </button>
<button <button
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
@ -459,8 +471,10 @@
[disabled]="dataSource.data.length === 0" [disabled]="dataSource.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export Activities</span> <span i18n>Export Activities</span>
</span>
</button> </button>
<button <button
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
@ -469,8 +483,10 @@
[disabled]="!hasDrafts" [disabled]="!hasDrafts"
(click)="onExportDrafts()" (click)="onExportDrafts()"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon> <ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span> <span i18n>Export Drafts as ICS</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</th> </th>
@ -486,24 +502,32 @@
</button> </button>
<mat-menu #activityMenu="matMenu" xPosition="before"> <mat-menu #activityMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateActivity(element)"> <button mat-menu-item (click)="onUpdateActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button mat-menu-item (click)="onCloneActivity(element)"> <button mat-menu-item (click)="onCloneActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline"></ion-icon> <ion-icon class="mr-2" name="copy-outline"></ion-icon>
<span i18n>Clone</span> <span i18n>Clone</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="!element.isDraft" [disabled]="!element.isDraft"
(click)="onExportDraft(element.id)" (click)="onExportDraft(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon> <ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Draft as ICS</span> <span i18n>Export Draft as ICS</span>
</span>
</button> </button>
<button mat-menu-item (click)="onDeleteActivity(element.id)"> <button mat-menu-item (click)="onDeleteActivity(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

21
libs/ui/src/lib/currency-selector/currency-selector.component.html

@ -0,0 +1,21 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="currencyAutocomplete"
/>
<mat-autocomplete
#currencyAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateCurrency($event)"
>
<mat-option
*ngFor="let currencyItem of filteredCurrencies"
class="line-height-1"
[value]="currencyItem"
>
{{ currencyItem.label }}
</mat-option>
</mat-autocomplete>

3
libs/ui/src/lib/currency-selector/currency-selector.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

167
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -0,0 +1,167 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormControl, FormGroupDirective, NgControl } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatFormFieldControl } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
import { Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
},
providers: [
{
provide: MatFormFieldControl,
useExisting: CurrencySelectorComponent
}
],
selector: 'gf-currency-selector',
styleUrls: ['./currency-selector.component.scss'],
templateUrl: 'currency-selector.component.html'
})
export class CurrencySelectorComponent
extends AbstractMatFormField<Currency>
implements OnInit, OnDestroy
{
@Input() private currencies: Currency[] = [];
@Input() private formControlName: string;
@ViewChild(MatInput) private input: MatInput;
@ViewChild('currencyAutocomplete')
public currencyAutocomplete: MatAutocomplete;
public control = new FormControl();
public filteredCurrencies: Currency[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
private readonly formGroupDirective: FormGroupDirective,
public readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'currency-selector';
}
public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
const formGroup = this.formGroupDirective.form;
if (formGroup) {
const control = formGroup.get(this.formControlName);
if (control) {
this.value = this.currencies.find(({ value }) => {
return value === control.value;
});
}
}
this.control.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (super.value?.value) {
super.value.value = null;
}
});
this.control.valueChanges
.pipe(
takeUntil(this.unsubscribeSubject),
startWith(''),
map((value) => {
return value ? this.filter(value) : this.currencies.slice();
})
)
.subscribe((values) => {
this.filteredCurrencies = values;
});
}
public displayFn(currency: Currency) {
return currency?.label ?? '';
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public onUpdateCurrency(event: MatAutocompleteSelectedEvent) {
super.value = {
label: event.option.value.label,
value: event.option.value.value
} as Currency;
}
public set value(value: Currency) {
const newValue =
typeof value === 'object' && value !== null ? { ...value } : value;
this.control.setValue(newValue);
super.value = newValue;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private filter(value: Currency | string) {
const filterValue =
typeof value === 'string'
? value?.toLowerCase()
: value?.value.toLowerCase();
return this.currencies.filter((currency) => {
return currency.value.toLowerCase().startsWith(filterValue);
});
}
private validateRequired() {
const requiredCheck = super.required
? !super.value.label || !super.value.value
: false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
}

23
libs/ui/src/lib/currency-selector/currency-selector.module.ts

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CurrencySelectorComponent } from './currency-selector.component';
@NgModule({
declarations: [CurrencySelectorComponent],
exports: [CurrencySelectorComponent],
imports: [
CommonModule,
FormsModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCurrencySelectorModule {}

0
libs/ui/src/lib/symbol-autocomplete/abstract-mat-form-field.ts → libs/ui/src/lib/shared/abstract-mat-form-field.ts

5
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -19,6 +19,7 @@ import { MatInput } from '@angular/material/input';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { Subject, tap } from 'rxjs'; import { Subject, tap } from 'rxjs';
import { import {
@ -29,8 +30,6 @@ import {
takeUntil takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
import { AbstractMatFormField } from './abstract-mat-form-field';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
@ -54,7 +53,7 @@ export class SymbolAutocompleteComponent
@Input() private includeIndices = false; @Input() private includeIndices = false;
@Input() public isLoading = false; @Input() public isLoading = false;
@ViewChild(MatInput, { static: false }) private input: MatInput; @ViewChild(MatInput) private input: MatInput;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete; @ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;

36
nx.json

@ -2,23 +2,6 @@
"affected": { "affected": {
"defaultBase": "origin/main" "defaultBase": "origin/main"
}, },
"npmScope": "ghostfolio",
"tasksRunnerOptions": {
"default": {
"runner": "nx-cloud",
"options": {
"accessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
"cacheableOperations": [
"build",
"lint",
"test",
"e2e",
"build-storybook"
],
"parallel": 1
}
}
},
"defaultProject": "api", "defaultProject": "api",
"generators": { "generators": {
"@nx/angular:application": { "@nx/angular:application": {
@ -37,13 +20,16 @@
"targetDefaults": { "targetDefaults": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["production", "^production"] "inputs": ["production", "^production"],
"cache": true
}, },
"e2e": { "e2e": {
"inputs": ["default", "^production"] "inputs": ["default", "^production"],
"cache": true
}, },
"test": { "test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"] "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"cache": true
}, },
"build-storybook": { "build-storybook": {
"inputs": [ "inputs": [
@ -52,7 +38,11 @@
"{workspaceRoot}/.storybook/**/*", "{workspaceRoot}/.storybook/**/*",
"{projectRoot}/.storybook/**/*", "{projectRoot}/.storybook/**/*",
"{projectRoot}/tsconfig.storybook.json" "{projectRoot}/tsconfig.storybook.json"
] ],
"cache": true
},
"lint": {
"cache": true
} }
}, },
"namedInputs": { "namedInputs": {
@ -72,5 +62,7 @@
"!{projectRoot}/tsconfig.storybook.json", "!{projectRoot}/tsconfig.storybook.json",
"!{projectRoot}/src/test-setup.[jt]s" "!{projectRoot}/src/test-setup.[jt]s"
] ]
} },
"nxCloudAccessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
"parallel": 1
} }

39
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.14.0", "version": "2.16.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -81,7 +81,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.4.2", "@prisma/client": "5.5.2",
"@simplewebauthn/browser": "8.3.1", "@simplewebauthn/browser": "8.3.1",
"@simplewebauthn/server": "8.3.2", "@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0", "@stripe/stripe-js": "1.47.0",
@ -122,14 +122,14 @@
"passport": "0.6.0", "passport": "0.6.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "5.4.2", "prisma": "5.5.2",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "11.12.0", "stripe": "11.12.0",
"svgmap": "2.6.0", "svgmap": "2.6.0",
"twitter-api-v2": "1.14.2", "twitter-api-v2": "1.14.2",
"uuid": "9.0.0", "uuid": "9.0.1",
"yahoo-finance2": "2.8.0", "yahoo-finance2": "2.8.1",
"zone.js": "0.13.1" "zone.js": "0.13.1"
}, },
"devDependencies": { "devDependencies": {
@ -146,21 +146,21 @@
"@angular/pwa": "16.2.0", "@angular/pwa": "16.2.0",
"@nestjs/schematics": "10.0.1", "@nestjs/schematics": "10.0.1",
"@nestjs/testing": "10.1.3", "@nestjs/testing": "10.1.3",
"@nx/angular": "16.7.4", "@nx/angular": "17.0.2",
"@nx/cypress": "16.7.4", "@nx/cypress": "17.0.2",
"@nx/eslint-plugin": "16.7.4", "@nx/eslint-plugin": "17.0.2",
"@nx/jest": "16.7.4", "@nx/jest": "17.0.2",
"@nx/js": "16.7.4", "@nx/js": "17.0.2",
"@nx/nest": "16.7.4", "@nx/nest": "17.0.2",
"@nx/node": "16.7.4", "@nx/node": "17.0.2",
"@nx/storybook": "16.7.4", "@nx/storybook": "17.0.2",
"@nx/web": "16.7.4", "@nx/web": "17.0.2",
"@nx/workspace": "16.7.4", "@nx/workspace": "17.0.2",
"@schematics/angular": "16.2.0", "@schematics/angular": "16.2.0",
"@simplewebauthn/typescript-types": "8.0.0", "@simplewebauthn/typescript-types": "8.0.0",
"@storybook/addon-essentials": "7.3.2", "@storybook/addon-essentials": "7.5.1",
"@storybook/angular": "7.3.2", "@storybook/angular": "7.5.1",
"@storybook/core-server": "7.3.2", "@storybook/core-server": "7.5.1",
"@types/big.js": "6.1.6", "@types/big.js": "6.1.6",
"@types/body-parser": "1.19.2", "@types/body-parser": "1.19.2",
"@types/cache-manager": "3.4.2", "@types/cache-manager": "3.4.2",
@ -187,8 +187,7 @@
"jest": "29.4.3", "jest": "29.4.3",
"jest-environment-jsdom": "29.4.3", "jest-environment-jsdom": "29.4.3",
"jest-preset-angular": "13.1.1", "jest-preset-angular": "13.1.1",
"nx": "16.7.4", "nx": "17.0.2",
"nx-cloud": "16.3.0",
"prettier": "3.0.3", "prettier": "3.0.3",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"react": "18.2.0", "react": "18.2.0",

5255
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save