Browse Source

Merge remote-tracking branch 'origin/main' into feature/add-stock-splits-logic

pull/7568/head
David Requeno 3 weeks ago
parent
commit
fb663d8df4
  1. 40
      CHANGELOG.md
  2. 22
      apps/api/src/app/account/account.service.ts
  3. 6
      apps/api/src/app/activities/activities.controller.ts
  4. 106
      apps/api/src/app/activities/activities.service.ts
  5. 21
      apps/api/src/app/import/import.service.ts
  6. 32
      apps/api/src/app/portfolio/portfolio.service.ts
  7. 3
      apps/api/src/app/user/user.service.ts
  8. 74
      apps/api/src/helper/activity.helper.ts
  9. 7
      apps/api/src/helper/country.helper.ts
  10. 10
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  11. 6
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  12. 20
      apps/api/src/services/tag/tag.service.ts
  13. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  14. 11
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  15. 2
      apps/client/src/app/components/header/header.component.html
  16. 11
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  17. 62
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  18. 27
      apps/client/src/app/components/home-holdings/home-holdings.html
  19. 6
      apps/client/src/app/components/home-holdings/home-holdings.scss
  20. 3
      apps/client/src/app/components/markets/markets.component.ts
  21. 36
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  22. 22
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  23. 296
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  24. 1
      apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts
  25. 197
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  26. 7
      apps/client/src/app/pages/accounts/accounts-page.html
  27. 28
      apps/client/src/app/pages/accounts/accounts-page.routes.ts
  28. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  29. 2
      apps/client/src/app/pages/open/open-page.html
  30. 4
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  31. 90
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  32. 64
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  33. 382
      apps/client/src/locales/messages.ca.xlf
  34. 394
      apps/client/src/locales/messages.de.xlf
  35. 382
      apps/client/src/locales/messages.es.xlf
  36. 382
      apps/client/src/locales/messages.fr.xlf
  37. 382
      apps/client/src/locales/messages.it.xlf
  38. 382
      apps/client/src/locales/messages.ja.xlf
  39. 382
      apps/client/src/locales/messages.ko.xlf
  40. 382
      apps/client/src/locales/messages.nl.xlf
  41. 382
      apps/client/src/locales/messages.pl.xlf
  42. 382
      apps/client/src/locales/messages.pt.xlf
  43. 382
      apps/client/src/locales/messages.tr.xlf
  44. 382
      apps/client/src/locales/messages.uk.xlf
  45. 380
      apps/client/src/locales/messages.xlf
  46. 382
      apps/client/src/locales/messages.zh.xlf
  47. 2
      libs/common/src/lib/config.ts
  48. 9
      libs/common/src/lib/helper.ts
  49. 2
      libs/common/src/lib/interfaces/index.ts
  50. 4
      libs/common/src/lib/interfaces/toggle-option.interface.ts
  51. 21
      libs/common/src/lib/routes/routes.ts
  52. 2
      libs/common/src/lib/types/index.ts
  53. 9
      libs/common/src/lib/types/toggle-option.type.ts
  54. 16
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  55. 26
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  56. 30
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  57. 4
      libs/ui/src/lib/activities-table/activities-table.component.html
  58. 11
      libs/ui/src/lib/activities-table/activities-table.component.ts
  59. 8
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  60. 3
      libs/ui/src/lib/assistant/assistant.component.ts
  61. 4
      libs/ui/src/lib/carousel/carousel.component.html
  62. 1
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  63. 26
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  64. 2
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  65. 10
      libs/ui/src/lib/entity-logo/entity-logo.component.scss
  66. 13
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  67. 1
      libs/ui/src/lib/i18n.ts
  68. 18
      libs/ui/src/lib/shared/abstract-mat-form-field.ts
  69. 4
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  70. 14
      libs/ui/src/lib/toggle/toggle.component.html
  71. 16
      libs/ui/src/lib/toggle/toggle.component.scss
  72. 43
      libs/ui/src/lib/toggle/toggle.component.stories.ts
  73. 6
      libs/ui/src/lib/toggle/toggle.component.ts
  74. 54
      package-lock.json
  75. 8
      package.json
  76. 13
      prisma/migrations/20260808120000_added_draft_tag_to_order/migration.sql
  77. 1
      prisma/schema.prisma
  78. 4
      prisma/seed.mts

40
CHANGELOG.md

@ -5,7 +5,41 @@ 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 ## 3.47.0 - 2026-08-10
### Changed
- Extended the toggle component to support a disabled state
- Extended the toggle component to support icons
- Reused the toggle component on the portfolio holdings page
- Reused the currency selector component in the user account settings
### Fixed
- Fixed the handling of the disabled state in the currency selector and symbol autocomplete components
- Fixed the restoration of the current selection in the currency selector component when leaving the field without picking an option
## 3.46.0 - 2026-08-09
### Added
- Added the _Draft_ tag, assigned automatically to activities dated in the future
### Changed
- Deprecated the `isDraft` attribute of the activity in favor of the _Draft_ tag
- Changed the activities count of an account to include draft activities
- Extended the _Draft_ tag to activities with a custom asset profile of type `BUY`
- Improved the language localization for German (`de`)
- Upgraded `bull-board` from version `8.1.2` to `8.6.0`
### Fixed
- Fixed the dividend and interest of an account by excluding draft activities
- Resolved an issue with unknown country names in the country weightings of the _Financial Modeling Prep_ service
- Resolved an issue with unknown country names in the data enhancer for asset profile data via _Trackinsight_
## 3.45.0 - 2026-08-08
### Added ### Added
@ -14,10 +48,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Aligned the x-axis of the dividend and investment timeline charts on the analysis page
- Improved the check for duplicates in the preview step of the activities import (regardless of the account) - Improved the check for duplicates in the preview step of the activities import (regardless of the account)
- Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account) - Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account)
- Extended the activities import to reuse an existing account of the user by name and currency - Extended the activities import to reuse an existing account of the user by name and currency
- Extended the activities import to resolve an ISIN to the symbol of the data provider - Extended the activities import to resolve an ISIN to the symbol of the data provider
- Improved the style of the placeholder in the entity logo component
- Migrated the create, detail and edit account dialogs to dedicated routes
- Improved the language localization for German (`de`)
### Fixed ### Fixed

22
apps/api/src/app/account/account.service.ts

@ -89,7 +89,10 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(AccountWithBalance & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & {
SymbolProfile?: SymbolProfile;
tags?: Pick<Tag, 'id'>[];
})[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];
@ -172,7 +175,7 @@ export class AccountService {
tagIds?: string[]; tagIds?: string[];
userId: string; userId: string;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -237,15 +240,10 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
let activitiesCount = 0; const result = {
...account,
for (const { isDraft } of account.activities) { activitiesCount: account.activities.length
if (!isDraft) { };
activitiesCount += 1;
}
}
const result = { ...account, activitiesCount };
delete result.activities; delete result.activities;
@ -317,7 +315,7 @@ export class AccountService {
userId: string; userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {

6
apps/api/src/app/activities/activities.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
@ -287,9 +288,9 @@ export class ActivitiesController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (dataSource && !activity.isDraft) { if (dataSource && !isActivityInFuture({ date: activity.date })) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and the date is not in the future
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -369,6 +370,7 @@ export class ActivitiesController {
}), }),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
originalDate: originalActivity.date,
userId: this.request.user.id, userId: this.request.user.id,
where: { where: {
id id

106
apps/api/src/app/activities/activities.service.ts

@ -7,6 +7,12 @@ import {
isAccountBalanceInFuture, isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper'; } from '@ghostfolio/api/helper/account.helper';
import {
getTagsWithDraftTag,
isActivityInFuture,
isDraftTagToBeAssigned,
WHERE_ACTIVITY_NOT_DRAFT
} from '@ghostfolio/api/helper/activity.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
@ -23,11 +29,13 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -51,7 +59,7 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -115,7 +123,7 @@ export class ActivitiesService {
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({ await this.tagService.validateTagIdsWithoutDraftTag({
userId, userId,
tagIds: tags.map(({ id }) => { tagIds: tags.map(({ id }) => {
return id; return id;
@ -123,6 +131,7 @@ export class ActivitiesService {
}); });
const activities = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
include: { tags: { select: { id: true } } },
where: { where: {
userId, userId,
SymbolProfile: { SymbolProfile: {
@ -132,20 +141,31 @@ export class ActivitiesService {
} }
}); });
const tagsToAssign = tags.map(({ id }) => {
return { id };
});
await Promise.all( await Promise.all(
activities.map(({ id }) => activities.map((activity) => {
this.prismaService.order.update({ // The set operation replaces all existing connections with the provided
// ones, hence the "Draft" tag of an individual activity is carried over
const isDraft = isDraftActivity(activity);
const tagsToSet = isDraft
? [...tagsToAssign, { id: TAG_ID_DRAFT }]
: tagsToAssign;
return this.prismaService.order.update({
data: { data: {
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft,
tags: { tags: {
// The set operation replaces all existing connections with the provided ones set: tagsToSet
set: tags.map((tag) => {
return { id: tag.id };
})
} }
}, },
where: { id } where: { id: activity.id }
});
}) })
)
); );
this.eventEmitter.emit( this.eventEmitter.emit(
@ -264,17 +284,21 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) const tagsToConnect = getTagsWithDraftTag({
? false tags,
: isAfter(data.date as Date, endOfToday()); date: data.date as Date,
draftTag: { id: TAG_ID_DRAFT },
type: data.type
});
const activity = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
isDraft, // @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToConnect }),
tags: { tags: {
connect: tags connect: tagsToConnect
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -640,8 +664,12 @@ export class ActivitiesService {
}; };
} }
if (includeDrafts === false) { const isFilteredByDraftTag = filtersByTag.some(({ id }) => {
where.isDraft = false; return id === TAG_ID_DRAFT;
});
if (includeDrafts === false && !isFilteredByDraftTag) {
andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
} }
if (filtersByAssetClass.length > 0) { if (filtersByAssetClass.length > 0) {
@ -983,6 +1011,7 @@ export class ActivitiesService {
public async updateActivity({ public async updateActivity({
data, data,
originalDate,
userId, userId,
where where
}: { }: {
@ -994,9 +1023,11 @@ export class ActivitiesService {
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
originalDate: Date;
userId: string; userId: string;
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
const areTagsProvided = data.tags !== undefined;
const tags = data.tags ?? []; const tags = data.tags ?? [];
await this.tagService.validateTagIds({ await this.tagService.validateTagIds({
@ -1010,8 +1041,6 @@ export class ActivitiesService {
data.comment = null; data.comment = null;
} }
let isDraft = false;
if ( if (
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
@ -1022,10 +1051,9 @@ export class ActivitiesService {
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); if (!isActivityInFuture({ date: data.date as Date })) {
// Gather symbol data of order in the background, if the date is not in
if (!isDraft) { // the future
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -1045,14 +1073,40 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Leave the tags untouched if the request does not provide them, so that a
// partial update cannot drop the "Draft" tag
let isDraft: boolean;
let tagsToUpdate: Prisma.OrderUpdateInput['tags'];
if (areTagsProvided) {
const tagsToSet = getTagsWithDraftTag({
originalDate,
tags,
date: data.date as Date,
draftTag: { id: TAG_ID_DRAFT },
type: data.type
});
isDraft = isDraftActivity({ tags: tagsToSet });
tagsToUpdate = { set: tagsToSet };
} else if (
isDraftTagToBeAssigned({
originalDate,
date: data.date as Date,
type: data.type
})
) {
isDraft = true;
tagsToUpdate = { connect: { id: TAG_ID_DRAFT } };
}
const activity = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft, isDraft,
tags: { tags: tagsToUpdate
set: tags
}
} }
}); });

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

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { getTagsWithDraftTag } from '@ghostfolio/api/helper/activity.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,6 +14,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix, ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -22,6 +24,7 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -41,7 +44,7 @@ import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client'; import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -713,6 +716,11 @@ export class ImportService {
}); });
} }
// Preview the "Draft" tag which createActivity() assigns in a real run
const draftTag = tags.find(({ id }) => {
return id === TAG_ID_DRAFT;
}) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' };
const activities: Activity[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -775,6 +783,13 @@ export class ImportService {
}); });
if (isDryRun) { if (isDryRun) {
const previewTags = getTagsWithDraftTag({
date,
draftTag,
type,
tags: validatedTags
});
order = { order = {
comment, comment,
currency, currency,
@ -788,7 +803,7 @@ export class ImportService {
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: randomUUID(), id: randomUUID(),
isDraft: isAfter(date, endOfToday()), isDraft: isDraftActivity({ tags: previewTags }),
SymbolProfile: { SymbolProfile: {
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -817,7 +832,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,
tags: validatedTags, tags: previewTags,
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id userId: user.id
}; };

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

@ -34,6 +34,7 @@ import {
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
@ -43,6 +44,7 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
getSum, getSum,
isAccountExcluded, isAccountExcluded,
isDraftActivity,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -174,7 +176,19 @@ export class PortfolioService {
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
activities: { include: { SymbolProfile: true } }, activities: {
include: {
SymbolProfile: true,
tags: {
select: {
id: true
},
where: {
id: TAG_ID_DRAFT
}
}
}
},
platform: true, platform: true,
tags: true tags: true
}, },
@ -200,12 +214,18 @@ export class PortfolioService {
for (const { for (const {
currency, currency,
date, date,
isDraft,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
type, type,
unitPrice unitPrice
} of account.activities) { } of account.activities) {
activitiesCount += 1;
if (isDraftActivity({ tags })) {
continue;
}
switch (type) { switch (type) {
case ActivityType.DIVIDEND: case ActivityType.DIVIDEND:
dividendInBaseCurrency += dividendInBaseCurrency +=
@ -226,10 +246,6 @@ export class PortfolioService {
)) ?? 0; )) ?? 0;
break; break;
} }
if (!isDraft) {
activitiesCount += 1;
}
} }
const valueInBaseCurrency = const valueInBaseCurrency =
@ -2101,8 +2117,8 @@ export class PortfolioService {
}) { }) {
return getSum( return getSum(
activities activities
.filter(({ isDraft, type }) => { .filter((activity) => {
return isDraft === false && type === activityType; return !isDraftActivity(activity) && activity.type === activityType;
}) })
.map(({ assetProfile, currency, quantity, unitPrice }) => { .map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big( return new Big(

3
apps/api/src/app/user/user.service.ts

@ -35,6 +35,7 @@ import {
PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY, THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
@ -195,7 +196,7 @@ export class UserService {
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = tags.filter(({ id }) => { tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return [TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS].includes(id);
}); });
} }

74
apps/api/src/helper/activity.helper.ts

@ -0,0 +1,74 @@
import {
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config';
import { Prisma, Type as ActivityType } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { uniqBy } from 'lodash';
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = {
tags: {
none: {
id: TAG_ID_DRAFT
}
}
};
export function getTagsWithDraftTag<T extends { id: string }>({
date,
draftTag,
endOfTodayDate = endOfToday(),
originalDate,
tags,
type
}: {
date: Date;
draftTag: T;
endOfTodayDate?: Date;
originalDate?: Date;
tags: T[];
type: ActivityType;
}) {
if (!isDraftTagToBeAssigned({ date, endOfTodayDate, originalDate, type })) {
return tags;
}
return uniqBy([...tags, draftTag], 'id');
}
export function isActivityInFuture({
date,
endOfTodayDate = endOfToday()
}: {
date: Date;
endOfTodayDate?: Date;
}) {
return isAfter(date, endOfTodayDate);
}
export function isDraftTagToBeAssigned({
date,
endOfTodayDate = endOfToday(),
originalDate,
type
}: {
date: Date;
endOfTodayDate?: Date;
originalDate?: Date;
type: ActivityType;
}) {
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(type)) {
return false;
}
if (!isActivityInFuture({ date, endOfTodayDate })) {
return false;
}
// Assign only when the date newly moves into the future, so that a tag the
// user has removed is not restored by an unrelated change
return originalDate
? !isActivityInFuture({ endOfTodayDate, date: originalDate })
: true;
}

7
apps/api/src/helper/country.helper.ts

@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
export function getCountryCodeByName({ export function getCountryCodeByName({
@ -17,5 +18,11 @@ export function getCountryCodeByName({
} }
} }
if (name) {
const logger = new Logger('getCountryCodeByName');
logger.warn(`Could not map the country "${name}" to a code`);
}
return undefined; return undefined;
} }

10
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -16,6 +16,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com'; private static baseUrl = 'https://www.trackinsight.com';
private static countriesMapping = { private static countriesMapping = {
'Czech Republic': 'CZ',
'Republic of Korea': 'KR', 'Republic of Korea': 'KR',
'Russian Federation': 'RU', 'Russian Federation': 'RU',
Turkey: 'TR', Turkey: 'TR',
@ -125,13 +126,14 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} holdings?.countries ?? {}
)) { )) {
response.countries.push({ const code = getCountryCodeByName({
code: getCountryCodeByName({
name, name,
aliases: TrackinsightDataEnhancerService.countriesMapping aliases: TrackinsightDataEnhancerService.countriesMapping
}),
weight: value.weight
}); });
if (code) {
response.countries.push({ code, weight: value.weight });
}
} }
} }

6
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -169,9 +169,6 @@ export class FinancialModelingPrepService
.then((res) => res.json()); .then((res) => res.json());
response.countries = etfCountryWeightings response.countries = etfCountryWeightings
.filter(({ country: countryName }) => {
return countryName.toLowerCase() !== 'other';
})
.map(({ country: countryName, weightPercentage }) => { .map(({ country: countryName, weightPercentage }) => {
return { return {
code: getCountryCodeByName({ code: getCountryCodeByName({
@ -180,6 +177,9 @@ export class FinancialModelingPrepService
}), }),
weight: parseFloat(`${weightPercentage}`) / 100 weight: parseFloat(`${weightPercentage}`) / 100
}; };
})
.filter(({ code }) => {
return !!code;
}); });
const etfHoldings = await this.fetchService const etfHoldings = await this.fetchService

20
apps/api/src/services/tag/tag.service.ts

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client'; import { Prisma, Tag } from '@prisma/client';
@ -160,4 +161,23 @@ export class TagService {
); );
} }
} }
public async validateTagIdsWithoutDraftTag({
tagIds,
userId
}: {
tagIds: string[];
userId: string;
}) {
// The "DRAFT" tag qualifies an individual activity and can therefore
// neither be assigned to an account nor to all activities of a holding
if (tagIds?.includes(TAG_ID_DRAFT)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.validateTagIds({ tagIds, userId });
}
} }

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

@ -176,6 +176,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
this.refreshUser();
}); });
} }
@ -195,6 +197,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
this.refreshUser();
}); });
} }
@ -413,4 +417,11 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchChart(); this.fetchChart();
this.fetchPortfolioHoldings(); this.fetchPortfolioHoldings();
} }
private refreshUser() {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
} }

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

@ -848,13 +848,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ price }) => { .subscribe(({ price }) => {
const currency = this.assetProfileForm.controls.currency.value;
this.notificationService.alert({ this.notificationService.alert({
title: title: `${$localize`The current market price is`} ${price}${
$localize`The current market price is` + currency ? ` ${currency}` : ''
' ' + }`
price +
' ' +
this.assetProfileForm.controls.currency.value
}); });
}); });
} }

2
apps/client/src/app/components/header/header.component.html

@ -217,7 +217,7 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@for (accessItem of user()?.access; track accessItem) { @for (accessItem of user()?.access; track accessItem.id) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)"> <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon

11
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -3,7 +3,8 @@ import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
E_MAIL_LINE_BREAK, E_MAIL_LINE_BREAK,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_4_FIGURES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
@ -591,7 +592,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
); );
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags
?.filter(({ id }) => {
// The "DRAFT" tag qualifies an individual activity and cannot
// be assigned to all activities of a holding
return id !== TAG_ID_DRAFT;
})
.map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)

62
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -3,12 +3,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types'; import {
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle'; import { GfToggleComponent } from '@ghostfolio/ui/toggle';
@ -24,11 +27,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons'; import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -36,14 +36,10 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfToggleComponent, GfToggleComponent,
GfTreemapChartComponent, GfTreemapChartComponent,
IonIcon,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -59,17 +55,29 @@ export class GfHomeHoldingsComponent implements OnInit {
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
protected holdingsViewMode: HoldingsViewMode =
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
protected readonly holdingsViewModeOptions: ToggleOption[] = [
{
iconName: 'reorder-four-outline',
title: $localize`Table`,
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: $localize`Chart`,
value: 'CHART'
}
];
protected holdingType: HoldingType = 'ACTIVE'; protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [ protected readonly holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
protected isHoldingsViewModeToggleDisabled = true;
protected readonly routerLinkPortfolioActivities = protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
protected user: User; protected user: User;
protected readonly viewModeFormControl = new FormControl<HoldingsViewMode>(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
@ -118,16 +126,13 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.viewModeFormControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => {
if (!holdingsViewMode) {
return;
} }
protected onChangeHoldingsViewMode(aHoldingsViewMode: HoldingsViewMode) {
this.holdingsViewMode = aHoldingsViewMode;
this.dataService this.dataService
.putUserSetting({ holdingsViewMode }) .putUserSetting({ holdingsViewMode: aHoldingsViewMode })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
@ -139,7 +144,6 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
}); });
});
} }
protected onChangeHoldingType(aHoldingType: HoldingType) { protected onChangeHoldingType(aHoldingType: HoldingType) {
@ -170,26 +174,22 @@ export class GfHomeHoldingsComponent implements OnInit {
} }
private initialize() { private initialize() {
this.viewModeFormControl.disable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = true;
if ( if (
this.hasPermissionToAccessHoldingsChart && this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE' this.holdingType === 'ACTIVE'
) { ) {
this.viewModeFormControl.enable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = false;
this.viewModeFormControl.setValue( this.holdingsViewMode =
this.deviceType === 'mobile' this.deviceType === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: (this.user?.settings?.holdingsViewMode ?? : (this.user?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE), GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE);
{ emitEvent: false } } else {
); this.holdingsViewMode =
} else if (this.holdingType === 'CLOSED') { GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
this.viewModeFormControl.setValue(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
} }
this.holdings = undefined; this.holdings = undefined;

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

@ -7,32 +7,23 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex"> <div class="d-flex">
<div class="d-flex"> <gf-toggle
<div class="d-none d-lg-block"> class="d-none d-lg-block"
<mat-button-toggle-group [defaultValue]="holdingsViewMode"
[formControl]="viewModeFormControl" [isDisabled]="isHoldingsViewModeToggleDisabled"
[hideSingleSelectionIndicator]="true" [options]="holdingsViewModeOptions"
> (valueChange)="onChangeHoldingsViewMode($event.value)"
<mat-button-toggle i18n-title title="Table" value="TABLE"> />
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" value="CHART">
<ion-icon name="grid-outline" />
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
<div class="align-items-center d-flex flex-grow-1 justify-content-end"> <div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="holdingType" [defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions" [options]="holdingTypeOptions"
(valueChange)="onChangeHoldingType($event.value)" (valueChange)="onChangeHoldingType($event.value)"
/> />
</div> </div>
</div> </div>
@if (viewModeFormControl.value === 'CHART') { @if (holdingsViewMode === 'CHART') {
<gf-treemap-chart <gf-treemap-chart
class="mt-3" class="mt-3"
cursor="pointer" cursor="pointer"
@ -44,7 +35,7 @@
(treemapChartClicked)="onHoldingClicked($event)" (treemapChartClicked)="onHoldingClicked($event)"
/> />
} }
<div [class.d-none]="viewModeFormControl.value !== 'TABLE'"> <div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table <gf-holdings-table
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

6
apps/client/src/app/components/home-holdings/home-holdings.scss

@ -1,9 +1,3 @@
:host { :host {
display: block; display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-button-toggle-height: 1.5rem;
}
}
} }

3
apps/client/src/app/components/markets/markets.component.ts

@ -5,11 +5,10 @@ import {
HistoricalDataItem, HistoricalDataItem,
InfoItem, InfoItem,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { FearAndGreedIndexMode } from '@ghostfolio/common/types'; import { FearAndGreedIndexMode, ToggleOption } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';

36
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -14,6 +14,7 @@ import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -30,8 +31,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormControl,
FormGroup,
NonNullableFormBuilder, NonNullableFormBuilder,
FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
@ -58,7 +60,7 @@ import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, GfCurrencySelectorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -77,7 +79,12 @@ import { catchError } from 'rxjs/operators';
}) })
export class GfUserAccountSettingsComponent implements OnInit { export class GfUserAccountSettingsComponent implements OnInit {
protected readonly appearancePlaceholder = $localize`Auto`; protected readonly appearancePlaceholder = $localize`Auto`;
protected readonly baseCurrency: string; protected readonly baseCurrencyForm = new FormGroup({
baseCurrency: new FormControl<string | null>({
disabled: true,
value: null
})
});
protected closeUserAccountMailHref: string; protected closeUserAccountMailHref: string;
protected readonly currencies: string[] = []; protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({ protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
@ -129,9 +136,8 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly webAuthnService = inject(WebAuthnService); private readonly webAuthnService = inject(WebAuthnService);
public constructor() { public constructor() {
const { baseCurrency, currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies; this.currencies = currencies;
this.userService.stateChanged this.userService.stateChanged
@ -182,6 +188,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.baseCurrencyForm.setValue({
baseCurrency: this.user.settings.baseCurrency ?? null
});
if (this.hasPermissionToUpdateUserSettings) {
this.baseCurrencyForm.enable({ emitEvent: false });
} else {
this.baseCurrencyForm.disable({ emitEvent: false });
}
if (this.user.settings.locale) { if (this.user.settings.locale) {
this.locales.push(this.user.settings.locale); this.locales.push(this.user.settings.locale);
} }
@ -194,6 +210,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
} }
}); });
this.baseCurrencyForm.controls.baseCurrency.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
// The currency selector emits null while the user is typing and only
// emits a currency once an option has been selected
if (value && value !== this.user?.settings.baseCurrency) {
this.onChangeUserSetting('baseCurrency', value);
}
});
addIcons({ eyeOffOutline, eyeOutline }); addIcons({ eyeOffOutline, eyeOutline });
} }

22
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -3,25 +3,17 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="d-flex py-1"> <div class="d-flex py-1">
<form #changeUserSettingsForm="ngForm" class="w-100"> <div class="w-100">
<div class="d-flex mb-2"> <div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50"> <div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container> <ng-container i18n>Base Currency</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50" [formGroup]="baseCurrencyForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select <gf-currency-selector
name="baseCurrency" formControlName="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings" [currencies]="currencies"
[value]="user.settings.baseCurrency" />
(selectionChange)="
onChangeUserSetting('baseCurrency', $event.value)
"
>
@for (currency of currencies; track currency) {
<mat-option [value]="currency">{{ currency }}</mat-option>
}
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@ -214,7 +206,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
</form> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">

296
apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

@ -0,0 +1,296 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { AccountResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
OnDestroy,
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of, Subject } from 'rxjs';
import {
distinctUntilChanged,
map,
switchMap,
takeUntil,
tap
} from 'rxjs/operators';
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces';
import { AccountDialogMode } from './types/account-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-account-dialog-host',
template: ''
})
export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent
>;
private readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
private readonly dialogClosed = new Subject<void>();
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as AccountDialogMode;
// The router reuses this component when only the account id changes, so
// the parameters are observed instead of read from the snapshot once
this.route.paramMap
.pipe(
map((paramMap) => {
return paramMap.get('accountId');
}),
distinctUntilChanged(),
tap(() => {
this.closeDialog();
}),
switchMap((accountId) => {
const account$: Observable<AccountResponse | undefined> =
mode === 'update' && accountId
? this.dataService.fetchAccount(accountId)
: of(undefined);
return this.userService.get().pipe(
switchMap((user) => {
return account$.pipe(
map((account) => {
return { account, accountId, user };
})
);
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ account, accountId, user }) => {
if (mode === 'detail') {
this.openAccountDetailDialog({ accountId, user });
return;
}
if (mode === 'update') {
if (
!account ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
const { balance, comment, currency, id, name, platformId, tags } =
account;
this.openCreateOrUpdateAccountDialog({
user,
account: {
balance,
comment,
currency,
id,
name,
platformId,
tags
},
isUpdate: true
});
return;
}
if (
!hasPermission(user?.permissions, permissions.createAccount) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
this.openCreateOrUpdateAccountDialog({
user,
account: {
balance: 0,
comment: null,
currency: user?.settings?.baseCurrency ?? null,
id: null,
name: null,
platformId: null,
tags: []
},
isUpdate: false
});
}
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
this.dialogClosed.complete();
}
private closeDialog() {
// Tear down the subscription of the dialog which is about to be replaced,
// so that its result is not mistaken for the user closing it
this.dialogClosed.next();
this.dialogRef?.close();
}
private isReadOnlyMode(user: User) {
return (
!!this.impersonationStorageService.getId() ||
!!user?.settings?.isRestrictedView
);
}
private navigateBack() {
void this.router.navigate(internalRoutes.accounts.routerLink);
}
private openAccountDetailDialog({
accountId,
user
}: {
accountId: string | null;
user: User;
}) {
if (!accountId) {
this.navigateBack();
return;
}
const impersonationId = this.impersonationStorageService.getId();
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
accountId,
impersonationId,
deviceType: this.deviceType(),
hasPermissionToCreateActivity:
!impersonationId &&
hasPermission(user?.permissions, permissions.createActivity) &&
!user?.settings?.isRestrictedView
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.navigateBack();
});
}
private openCreateOrUpdateAccountDialog({
account,
isUpdate,
user
}: {
account: CreateOrUpdateAccountDialogParams['account'];
isUpdate: boolean;
user: User;
}) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams,
CreateAccountDto | UpdateAccountDto | null
>(GfCreateOrUpdateAccountDialogComponent, {
data: {
account,
user
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putAccount(result as UpdateAccountDto)
: this.dataService.postAccount(result as CreateAccountDto);
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
error: () => {
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating back
// destroys this component and the refreshed user is what makes the
// accounts page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

1
apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts

@ -0,0 +1 @@
export type AccountDialogMode = 'create' | 'detail' | 'update';

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

@ -1,17 +1,9 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { TransferBalanceDto } from '@ghostfolio/common/dtos';
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -30,13 +22,10 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Tag } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { GfCreateOrUpdateAccountDialogComponent } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from './create-or-update-account-dialog/interfaces/interfaces';
import { TransferBalanceDialogParams } from './transfer-balance/interfaces/interfaces'; import { TransferBalanceDialogParams } from './transfer-balance/interfaces/interfaces';
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@ -54,10 +43,13 @@ export class GfAccountsPageComponent implements OnInit {
protected hasPermissionToCreateAccount: boolean; protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToUpdateAccount: boolean; protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null; protected impersonationId: string | null;
protected readonly internalRoutes = internalRoutes;
protected totalBalanceInBaseCurrency = 0; protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0; protected totalValueInBaseCurrency = 0;
protected user: User; protected user: User;
private isInitialFetch = true;
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
@ -79,26 +71,7 @@ export class GfAccountsPageComponent implements OnInit {
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) { if (params['transferBalanceDialog']) {
this.openAccountDetailDialog(params['accountId']);
} else if (
params['createDialog'] &&
this.hasPermissionToCreateAccount
) {
this.openCreateAccountDialog();
} else if (params['editDialog']) {
if (this.accounts) {
const account = this.accounts.find(({ id }) => {
return id === params['accountId'];
});
if (account) {
this.openUpdateAccountDialog(account);
}
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
} else if (params['transferBalanceDialog']) {
this.openTransferBalanceDialog(); this.openTransferBalanceDialog();
} }
}); });
@ -130,12 +103,12 @@ export class GfAccountsPageComponent implements OnInit {
this.user.permissions, this.user.permissions,
permissions.updateAccount permissions.updateAccount
); );
this.fetchAccounts();
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.fetchAccounts();
} }
protected onDeleteAccount(aId: string) { protected onDeleteAccount(aId: string) {
@ -149,8 +122,6 @@ export class GfAccountsPageComponent implements OnInit {
.get(true) .get(true)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchAccounts();
}); });
} }
@ -160,12 +131,6 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected onUpdateAccount(aAccount: AccountWithValue) {
this.router.navigate([], {
queryParams: { accountId: aAccount.id, editDialog: true }
});
}
private fetchAccounts() { private fetchAccounts() {
this.dataService this.dataService
.fetchAccounts() .fetchAccounts()
@ -182,149 +147,21 @@ export class GfAccountsPageComponent implements OnInit {
this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency; this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency;
this.totalValueInBaseCurrency = totalValueInBaseCurrency; this.totalValueInBaseCurrency = totalValueInBaseCurrency;
if (this.accounts?.length <= 0) { if (
this.router.navigate([], { queryParams: { createDialog: true } }); this.accounts?.length <= 0 &&
} this.hasPermissionToCreateAccount &&
this.isInitialFetch
this.changeDetectorRef.markForCheck(); ) {
} void this.router.navigate(
internalRoutes.accounts.subRoutes.create.routerLink
); );
} }
private openUpdateAccountDialog({ this.isInitialFetch = false;
balance,
comment,
currency,
id,
name,
platformId,
tags
}: AccountWithValue & { tags?: Tag[] }) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams
>(GfCreateOrUpdateAccountDialogComponent, {
data: {
account: {
balance,
comment,
currency,
id,
name,
platformId,
tags
},
user: this.user
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((account: UpdateAccountDto | null) => {
if (account) {
this.reset();
this.dataService
.putAccount(account)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchAccounts();
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.fetchAccounts();
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openCreateAccountDialog() {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams
>(GfCreateOrUpdateAccountDialogComponent, {
data: {
account: {
balance: 0,
comment: null,
currency: this.user?.settings?.baseCurrency ?? null,
id: null,
name: null,
platformId: null,
tags: []
},
user: this.user
} satisfies CreateOrUpdateAccountDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((account: CreateAccountDto | null) => {
if (account) {
this.reset();
this.dataService
.postAccount(account)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchAccounts();
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
);
this.router.navigate(['.'], { relativeTo: this.route });
});
} }
private openTransferBalanceDialog() { private openTransferBalanceDialog() {

7
apps/client/src/app/pages/accounts/accounts-page.html

@ -15,7 +15,6 @@
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"
(accountDeleted)="onDeleteAccount($event)" (accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
(transferBalance)="onTransferBalance()" (transferBalance)="onTransferBalance()"
/> />
</div> </div>
@ -26,6 +25,10 @@
hasPermissionToCreateAccount && hasPermissionToCreateAccount &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<gf-fab [queryParams]="{ createDialog: true }" /> <gf-fab
[routerLink]="internalRoutes.accounts.subRoutes.create.routerLink"
/>
} }
</div> </div>
<router-outlet />

28
apps/client/src/app/pages/accounts/accounts-page.routes.ts

@ -3,11 +3,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component';
import { GfAccountsPageComponent } from './accounts-page.component'; import { GfAccountsPageComponent } from './accounts-page.component';
const { create, detail, update } = internalRoutes.accounts.subRoutes;
export const routes: Routes = [ export const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [
{
component: GfAccountDialogHostComponent,
data: { mode: 'create' },
path: create.path,
title: create.title
},
{
children: [
{
component: GfAccountDialogHostComponent,
data: { mode: 'detail' },
path: '',
title: detail.title
},
{
component: GfAccountDialogHostComponent,
data: { mode: 'update' },
path: update.path,
title: update.title
}
],
path: ':accountId'
}
],
component: GfAccountsPageComponent, component: GfAccountsPageComponent,
path: '', path: '',
title: internalRoutes.accounts.title title: internalRoutes.accounts.title

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

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -92,7 +93,13 @@ export class GfCreateOrUpdateAccountDialogComponent {
); );
this.tagsAvailable = this.tagsAvailable =
this.data.user?.tags?.map((tag) => { this.data.user?.tags
?.filter(({ id }) => {
// The "DRAFT" tag qualifies an individual activity and cannot be
// assigned to an account
return id !== TAG_ID_DRAFT;
})
.map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)

2
apps/client/src/app/pages/open/open-page.html

@ -27,7 +27,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<mat-card appearance="outlined"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">

4
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -331,7 +331,9 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToCreateActivity = this.hasPermissionToCreateActivity =
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createActivity); hasPermission(this.user.permissions, permissions.createActivity) &&
!this.user.settings?.isRestrictedView;
this.hasPermissionToDeleteActivity = this.hasPermissionToDeleteActivity =
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.deleteActivity); hasPermission(this.user.permissions, permissions.deleteActivity);

90
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -1,6 +1,8 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces'; import { Activity, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -16,8 +18,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of } from 'rxjs'; import { EMPTY, Observable, of, Subject } from 'rxjs';
import { map, switchMap } from 'rxjs/operators'; import {
catchError,
distinctUntilChanged,
map,
switchMap,
takeUntil,
tap
} from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component'; import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces';
@ -31,25 +40,39 @@ import { ActivityDialogMode } from './types/activity-dialog-mode.type';
export class GfActivityDialogHostComponent implements OnDestroy, OnInit { export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>; private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
private readonly dialogClosed = new Subject<void>();
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public ngOnInit() { public ngOnInit() {
const mode = this.route.snapshot.data.mode as ActivityDialogMode; const mode = this.route.snapshot.data.mode as ActivityDialogMode;
const activityId = this.route.snapshot.paramMap.get('activityId');
// The router reuses this component when only the activity id changes, so
// the parameters are observed instead of read from the snapshot once
this.route.paramMap
.pipe(
map((paramMap) => {
return paramMap.get('activityId');
}),
distinctUntilChanged(),
tap(() => {
this.closeDialog();
}),
switchMap((activityId) => {
const activity$: Observable<Activity | undefined> = activityId const activity$: Observable<Activity | undefined> = activityId
? this.dataService.fetchActivity(activityId) ? this.dataService.fetchActivity(activityId)
: of(undefined); : of(undefined);
this.userService return this.userService.get().pipe(
.get()
.pipe(
switchMap((user) => { switchMap((user) => {
return activity$.pipe( return activity$.pipe(
map((activity) => { map((activity) => {
@ -57,15 +80,22 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
}) })
); );
}), }),
catchError(() => {
this.navigateBack();
return EMPTY;
})
);
}),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe({ .subscribe(({ activity, user }) => {
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
if (mode === 'update') { if (mode === 'update') {
if (!activity) { if (
!activity ||
!hasPermission(user?.permissions, permissions.updateActivity) ||
this.isReadOnlyMode(user)
) {
this.navigateBack(); this.navigateBack();
return; return;
@ -82,6 +112,16 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
return; return;
} }
// Cloning creates a new activity as well
if (
!hasPermission(user?.permissions, permissions.createActivity) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
this.openDialog({ this.openDialog({
user, user,
activity: { activity: {
@ -96,7 +136,6 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
}, },
isUpdate: false isUpdate: false
}); });
}
}); });
} }
@ -105,6 +144,23 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
// be closed explicitly when leaving the route (for example via the browser // be closed explicitly when leaving the route (for example via the browser
// navigation) // navigation)
this.dialogRef?.close(); this.dialogRef?.close();
this.dialogClosed.complete();
}
private closeDialog() {
// Tear down the subscription of the dialog which is about to be replaced,
// so that its result is not mistaken for the user closing it
this.dialogClosed.next();
this.dialogRef?.close();
}
private isReadOnlyMode(user: User) {
return (
!!this.impersonationStorageService.getId() ||
!!user?.settings?.isRestrictedView
);
} }
private navigateBack() { private navigateBack() {
@ -124,7 +180,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
}) { }) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent, GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, { >(GfCreateOrUpdateActivityDialogComponent, {
@ -137,9 +193,11 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
width: deviceType === 'mobile' ? '100vw' : '50rem' width: deviceType === 'mobile' ? '100vw' : '50rem'
}); });
this.dialogRef this.dialogRef = dialogRef;
dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => { .subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) { if (!result) {
this.navigateBack(); this.navigateBack();

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

@ -13,7 +13,6 @@ import {
PortfolioInvestmentsResponse, PortfolioInvestmentsResponse,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
@ -21,7 +20,11 @@ import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types'; import type {
AiPromptMode,
GroupBy,
ToggleOption
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -51,10 +54,11 @@ import { IonIcon } from '@ionic/angular/standalone';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { copyOutline, ellipsisVertical } from 'ionicons/icons'; import { copyOutline, ellipsisVertical } from 'ionicons/icons';
import { isNumber, sortBy } from 'lodash'; import { isNumber, keyBy, sortBy, union } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -254,32 +258,51 @@ export class GfAnalysisPageComponent implements OnInit {
this.isLoadingDividendTimelineChart = true; this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true; this.isLoadingInvestmentTimelineChart = true;
this.dataService forkJoin({
.fetchDividends({ dividends: this.dataService.fetchDividends({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
groupBy: this.mode(), groupBy: this.mode(),
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}),
investments: this.dataService.fetchInvestments({
filters: this.userService.getFilters(),
groupBy: this.mode(),
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dividends }) => { .subscribe(
this.dividendsByGroup = dividends; ({
dividends: { dividends },
investments: { investments, savingsRate, streaks }
}) => {
// Expand both timelines to the union of their groups so that the
// charts share the same axis, independent of whether a dividend or
// an investment has been tracked in a given group
const dividendByDate = keyBy(dividends, 'date');
const investmentByDate = keyBy(investments, 'date');
const dates = sortBy(
union(Object.keys(dividendByDate), Object.keys(investmentByDate))
);
this.isLoadingDividendTimelineChart = false; this.dividendsByGroup = dates.map((date) => {
return {
date,
investment: dividendByDate[date]?.investment ?? 0
};
});
this.changeDetectorRef.markForCheck(); this.investmentsByGroup = dates.map((date) => {
return {
date,
investment: investmentByDate[date]?.investment ?? 0
};
}); });
this.dataService
.fetchInvestments({
filters: this.userService.getFilters(),
groupBy: this.mode(),
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, savingsRate, streaks }) => {
this.investmentsByGroup = investments;
this.savingsRatePerMonth = savingsRate; this.savingsRatePerMonth = savingsRate;
this.streaks = streaks; this.streaks = streaks;
this.unitCurrentStreak = this.unitCurrentStreak =
this.mode() === 'year' this.mode() === 'year'
? this.streaks?.currentStreak === 1 ? this.streaks?.currentStreak === 1
@ -288,6 +311,7 @@ export class GfAnalysisPageComponent implements OnInit {
: this.streaks?.currentStreak === 1 : this.streaks?.currentStreak === 1
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.unitLongestStreak = this.unitLongestStreak =
this.mode() === 'year' this.mode() === 'year'
? this.streaks?.longestStreak === 1 ? this.streaks?.longestStreak === 1
@ -297,10 +321,12 @@ export class GfAnalysisPageComponent implements OnInit {
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.isLoadingDividendTimelineChart = false;
this.isLoadingInvestmentTimelineChart = false; this.isLoadingInvestmentTimelineChart = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
);
} }
private update() { private update() {

382
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

382
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

382
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

382
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

382
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

382
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

2
libs/common/src/lib/config.ts

@ -343,12 +343,14 @@ export const SUPPORTED_LANGUAGE_CODES = [
] as const; ] as const;
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const TAG_ID_DRAFT = '0c077abd-eca2-4cbb-818c-6cefbf2d169a';
export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180';
export const TAG_ID_EXCLUDE_FROM_ANALYSIS = export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd'; 'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_IDS_SYSTEM = [ export const TAG_IDS_SYSTEM = [
TAG_ID_DEMO, TAG_ID_DEMO,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
]; ];

9
libs/common/src/lib/helper.ts

@ -43,6 +43,7 @@ import {
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix, ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
TAG_IDS_SYSTEM TAG_IDS_SYSTEM
} from './config'; } from './config';
@ -537,6 +538,14 @@ export function isDerivedCurrency(aCurrency: string) {
}); });
} }
export function isDraftActivity(activity?: { tags?: { id: string }[] }) {
return (
activity?.tags?.some(({ id }) => {
return id === TAG_ID_DRAFT;
}) === true
);
}
export function isRootCurrency(aCurrency: string) { export function isRootCurrency(aCurrency: string) {
if (aCurrency === 'USD') { if (aCurrency === 'USD') {
return true; return true;

2
libs/common/src/lib/interfaces/index.ts

@ -94,7 +94,6 @@ import type { SubscriptionOffer } from './subscription-offer.interface';
import type { SymbolItem } from './symbol-item.interface'; import type { SymbolItem } from './symbol-item.interface';
import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface'; import type { SystemMessage } from './system-message.interface';
import type { ToggleOption } from './toggle-option.interface';
import type { UserItem } from './user-item.interface'; import type { UserItem } from './user-item.interface';
import type { UserSettings } from './user-settings.interface'; import type { UserSettings } from './user-settings.interface';
import type { User } from './user.interface'; import type { User } from './user.interface';
@ -191,7 +190,6 @@ export {
SymbolItem, SymbolItem,
SymbolMetrics, SymbolMetrics,
SystemMessage, SystemMessage,
ToggleOption,
User, User,
UserItem, UserItem,
UserSettings, UserSettings,

4
libs/common/src/lib/interfaces/toggle-option.interface.ts

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

21
libs/common/src/lib/routes/routes.ts

@ -66,6 +66,27 @@ export const internalRoutes = {
accounts: { accounts: {
path: 'accounts', path: 'accounts',
routerLink: ['/accounts'], routerLink: ['/accounts'],
subRoutes: {
create: {
path: 'create',
routerLink: ['/accounts', 'create'],
title: $localize`Add Account`
},
detail: {
path: undefined, // Default sub route
routerLink: (aAccountId: string) => {
return ['/accounts', aAccountId];
},
title: $localize`Account`
},
update: {
path: 'update',
routerLink: (aAccountId: string) => {
return ['/accounts', aAccountId, 'update'];
},
title: $localize`Update Account`
}
},
title: $localize`Accounts` title: $localize`Accounts`
}, },
api: { api: {

2
libs/common/src/lib/types/index.ts

@ -23,6 +23,7 @@ import type { PropertyKey } from './property-key.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
import type { SectorName } from './sector-name.type'; import type { SectorName } from './sector-name.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type';
import type { ToggleOption } from './toggle-option.type';
import type { UserWithSettings } from './user-with-settings.type'; import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type'; import type { ViewMode } from './view-mode.type';
@ -52,6 +53,7 @@ export type {
RequestWithUser, RequestWithUser,
SectorName, SectorName,
SubscriptionOfferKey, SubscriptionOfferKey,
ToggleOption,
UserWithSettings, UserWithSettings,
ViewMode ViewMode
}; };

9
libs/common/src/lib/types/toggle-option.type.ts

@ -0,0 +1,9 @@
interface BaseToggleOption {
value: string;
}
export type ToggleOption = BaseToggleOption &
(
| { iconName: string; label?: never; title: string }
| { iconName?: never; label: string; title?: never }
);

16
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -307,18 +307,26 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onOpenAccountDetailDialog(element.id)"> @if (hasPermissionToOpenDetails()) {
<a
mat-menu-item
[routerLink]="accountDialogRouterLinks().get(element.id)?.detail"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="wallet-outline" /> <ion-icon class="mr-2" name="wallet-outline" />
<span><ng-container i18n>View Details</ng-container>...</span> <span><ng-container i18n>View Details</ng-container>...</span>
</span> </span>
</button> </a>
<button mat-menu-item (click)="onUpdateAccount(element)"> }
<a
mat-menu-item
[routerLink]="accountDialogRouterLinks().get(element.id)?.update"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span> <span><ng-container i18n>Edit</ng-container>...</span>
</span> </span>
</button> </a>
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item

26
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -5,9 +5,9 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { provideRouter, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular'; import { applicationConfig, moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -92,6 +92,9 @@ export default {
title: 'Accounts Table', title: 'Accounts Table',
component: GfAccountsTableComponent, component: GfAccountsTableComponent,
decorators: [ decorators: [
applicationConfig({
providers: [provideRouter([])]
}),
moduleMetadata({ moduleMetadata({
imports: [ imports: [
CommonModule, CommonModule,
@ -147,6 +150,25 @@ export const Default: Story = {
} }
}; };
export const Actions: Story = {
args: {
accounts,
activitiesCount: 12,
baseCurrency: 'USD',
hasPermissionToOpenDetails: true,
locale: 'en-US',
showActions: true,
showActivitiesCount: true,
showAllocationInPercentage: false,
showBalance: true,
showFooter: true,
showValue: true,
showValueInBaseCurrency: true,
totalBalanceInBaseCurrency: 12428.2,
totalValueInBaseCurrency: 107971.70321466809
}
};
export const WithoutFooter: Story = { export const WithoutFooter: Story = {
args: { args: {
accounts, accounts,

30
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -4,6 +4,7 @@ import {
getLowercase, getLowercase,
isAccountExcluded isAccountExcluded
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
@ -71,11 +72,28 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>(); public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>(); public readonly accountDeleted = output<string>();
public readonly accountToUpdate = output<AccountWithValue>();
public readonly transferBalance = output<void>(); public readonly transferBalance = output<void>();
public readonly sort = viewChild.required(MatSort); public readonly sort = viewChild.required(MatSort);
protected readonly accountDialogRouterLinks = computed(() => {
const { detail, update } = internalRoutes.accounts.subRoutes;
const routerLinks = new Map<
string,
{ detail: string[]; update: string[] }
>();
for (const { id } of this.accounts() ?? []) {
routerLinks.set(id, {
detail: detail.routerLink(id),
update: update.routerLink(id)
});
}
return routerLinks;
});
protected readonly dataSource = new MatTableDataSource<AccountWithValue>([]); protected readonly dataSource = new MatTableDataSource<AccountWithValue>([]);
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
@ -159,9 +177,9 @@ export class GfAccountsTableComponent {
protected onOpenAccountDetailDialog(accountId: string) { protected onOpenAccountDetailDialog(accountId: string) {
if (this.hasPermissionToOpenDetails()) { if (this.hasPermissionToOpenDetails()) {
this.router.navigate([], { void this.router.navigate(
queryParams: { accountId, accountDetailDialog: true } internalRoutes.accounts.subRoutes.detail.routerLink(accountId)
}); );
} }
} }
@ -174,8 +192,4 @@ export class GfAccountsTableComponent {
protected onTransferBalance() { protected onTransferBalance() {
this.transferBalance.emit(); this.transferBalance.emit();
} }
protected onUpdateAccount(aAccount: AccountWithValue) {
this.accountToUpdate.emit(aAccount);
}
} }

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

@ -177,7 +177,7 @@
<td *matCellDef="let element" class="line-height-normal px-1" mat-cell> <td *matCellDef="let element" class="line-height-normal px-1" mat-cell>
<div class="align-items-center d-flex text-nowrap"> <div class="align-items-center d-flex text-nowrap">
<span class="text-truncate">{{ element.assetProfile?.name }}</span> <span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) { @if (isDraftActivity(element)) {
<span class="badge badge-secondary ml-1" i18n>Draft</span> <span class="badge badge-secondary ml-1" i18n>Draft</span>
} }
</div> </div>
@ -513,7 +513,7 @@
</a> </a>
<button <button
mat-menu-item mat-menu-item
[disabled]="!element.isDraft" [disabled]="!isDraftActivity(element)"
(click)="onExportDraft(element.id)" (click)="onExportDraft(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

11
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -3,7 +3,11 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper'; import {
getLocale,
isAccountExcluded,
isDraftActivity
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
AssetProfileIdentifier AssetProfileIdentifier
@ -138,6 +142,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public activityTypesTranslationMap = new Map<ActivityType, string>(); public activityTypesTranslationMap = new Map<ActivityType, string>();
public hasDrafts = false; public hasDrafts = false;
public hasErrors = false; public hasErrors = false;
public isDraftActivity = isDraftActivity;
public isUUID = isUUID; public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []); public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]); public typesFilter = new FormControl<string[]>([]);
@ -277,7 +282,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
return ( return (
this.hasPermissionToOpenDetails && this.hasPermissionToOpenDetails &&
this.isExcludedFromAnalysis(activity) === false && this.isExcludedFromAnalysis(activity) === false &&
activity.isDraft === false && isDraftActivity(activity) === false &&
['BUY', 'DIVIDEND', 'SELL'].includes(activity.type) ['BUY', 'DIVIDEND', 'SELL'].includes(activity.type)
); );
} }
@ -357,7 +362,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.exportDrafts.emit( this.exportDrafts.emit(
this.dataSource() this.dataSource()
?.filteredData.filter((activity) => { ?.filteredData.filter((activity) => {
return activity.isDraft; return isDraftActivity(activity);
}) })
.map((activity) => { .map((activity) => {
return activity.id; return activity.id;

8
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -52,12 +52,8 @@ export class GfAssistantListItemComponent
public ngOnChanges() { public ngOnChanges() {
if (this.item?.mode === SearchMode.ACCOUNT) { if (this.item?.mode === SearchMode.ACCOUNT) {
this.queryParams = { this.queryParams = {};
accountDetailDialog: true, this.routerLink = this.item.routerLink;
accountId: this.item.id
};
this.routerLink = internalRoutes.accounts.routerLink;
} else if (this.item?.mode === SearchMode.ASSET_PROFILE) { } else if (this.item?.mode === SearchMode.ASSET_PROFILE) {
this.queryParams = { this.queryParams = {
assetProfileDialog: true, assetProfileDialog: true,

3
libs/ui/src/lib/assistant/assistant.component.ts

@ -612,7 +612,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return { return {
id, id,
name, name,
routerLink: internalRoutes.accounts.routerLink, routerLink:
internalRoutes.accounts.subRoutes.detail.routerLink(id),
mode: SearchMode.ACCOUNT as const mode: SearchMode.ACCOUNT as const
}; };
}); });

4
libs/ui/src/lib/carousel/carousel.component.html

@ -1,4 +1,4 @@
@if (this.showPrevArrow) { @if (showPrevArrow) {
<button <button
aria-hidden="true" aria-hidden="true"
aria-label="previous" aria-label="previous"
@ -17,7 +17,7 @@
</div> </div>
</div> </div>
@if (this.showNextArrow) { @if (showNextArrow) {
<button <button
aria-hidden="true" aria-hidden="true"
aria-label="next" aria-label="next"

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

@ -13,6 +13,7 @@
<mat-autocomplete <mat-autocomplete
#currencyAutocomplete="matAutocomplete" #currencyAutocomplete="matAutocomplete"
(closed)="onPanelClosed()"
(optionSelected)="onUpdateCurrency($event)" (optionSelected)="onUpdateCurrency($event)"
> >
@for (currency of filteredCurrencies; track currency) { @for (currency of filteredCurrencies; track currency) {

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

@ -81,6 +81,7 @@ export class GfCurrencySelectorComponent
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formField = inject(MAT_FORM_FIELD); private readonly formField = inject(MAT_FORM_FIELD);
private readonly input = viewChild.required(MatInput); private readonly input = viewChild.required(MatInput);
private lastSelectedCurrency: string | null = null;
public constructor( public constructor(
public override readonly _elementRef: ElementRef, public override readonly _elementRef: ElementRef,
@ -110,9 +111,15 @@ export class GfCurrencySelectorComponent
return !this.control.value; return !this.control.value;
} }
public override get value() {
return super.value;
}
public override set value(value: string | null) { public override set value(value: string | null) {
this.control.setValue(value); this.control.setValue(value);
super.value = value; super.value = value;
this.lastSelectedCurrency = value;
} }
public focus() { public focus() {
@ -124,10 +131,6 @@ export class GfCurrencySelectorComponent
} }
public ngOnInit() { public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
const formGroup = this.formGroupDirective.form; const formGroup = this.formGroupDirective.form;
if (formGroup) { if (formGroup) {
@ -170,8 +173,23 @@ export class GfCurrencySelectorComponent
} }
} }
public onPanelClosed() {
// Typing clears the selected currency, so restore the last selection once
// the panel closes without an option having been picked. An empty input is
// left untouched to allow clearing the currency.
if (super.value || !this.control.value) {
return;
}
this.value = this.lastSelectedCurrency;
this.changeDetectorRef.markForCheck();
}
public onUpdateCurrency({ option }: { option: MatOption<string> }) { public onUpdateCurrency({ option }: { option: MatOption<string> }) {
super.value = option.value; super.value = option.value;
this.lastSelectedCurrency = option.value;
} }
private filter(value: string) { private filter(value: string) {

2
libs/ui/src/lib/entity-logo/entity-logo.component.html

@ -7,5 +7,5 @@
(error)="onError()" (error)="onError()"
/> />
} @else if (hasPlaceholder) { } @else if (hasPlaceholder) {
<div class="logo placeholder" [class.large]="size === 'large'"></div> <div class="logo" [class.large]="size === 'large'"></div>
} }

10
libs/ui/src/lib/entity-logo/entity-logo.component.scss

@ -11,15 +11,5 @@
height: 1.4rem; height: 1.4rem;
width: 1.4rem; width: 1.4rem;
} }
&.placeholder {
border: 1px solid rgba(var(--dark-dividers));
}
}
}
:host-context(.theme-dark) {
.placeholder {
border-color: rgba(var(--light-dividers));
} }
} }

13
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -45,18 +45,17 @@ export const LogoByUrl: Story = {
} }
}; };
export const Placeholder: Story = { export const LogoOnError: Story = {
args: { args: {
hasPlaceholder: true, size: 'large',
size: 'large' tooltip: 'Unknown',
url: 'https://unknown.ghostfol.io'
} }
}; };
export const PlaceholderOnError: Story = { export const Placeholder: Story = {
args: { args: {
hasPlaceholder: true, hasPlaceholder: true,
size: 'large', size: 'large'
tooltip: 'Unknown',
url: 'https://unknown.ghostfol.io'
} }
}; };

1
libs/ui/src/lib/i18n.ts

@ -18,6 +18,7 @@ const locales = {
DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_SOURCE: $localize`Data Source`, DATA_SOURCE: $localize`Data Source`,
DRAFT: $localize`Draft`,
EMERGENCY_FUND: $localize`Emergency Fund`, EMERGENCY_FUND: $localize`Emergency Fund`,
EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`,
Global: $localize`Global`, Global: $localize`Global`,

18
libs/ui/src/lib/shared/abstract-mat-form-field.ts

@ -9,7 +9,12 @@ import {
Input, Input,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import {
ControlValueAccessor,
FormControl,
NgControl,
Validators
} from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field'; import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -23,6 +28,7 @@ export abstract class AbstractMatFormField<T>
@HostBinding('attr.aria-describedBy') public describedBy = ''; @HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean; public readonly autofilled: boolean;
public abstract readonly control: FormControl;
public errorState: boolean; public errorState: boolean;
public focused = false; public focused = false;
public readonly stateChanges = new Subject<void>(); public readonly stateChanges = new Subject<void>();
@ -156,6 +162,16 @@ export abstract class AbstractMatFormField<T>
this.describedBy = ids.join(' '); this.describedBy = ids.join(' ');
} }
public setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.control.disable({ emitEvent: false });
} else {
this.control.enable({ emitEvent: false });
}
this.stateChanges.next();
}
public writeValue(value: T) { public writeValue(value: T) {
this.value = value; this.value = value;
} }

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

@ -115,10 +115,6 @@ export class GfSymbolAutocompleteComponent
} }
public ngOnInit() { public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
this.control.valueChanges this.control.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {

14
libs/ui/src/lib/toggle/toggle.component.html

@ -9,11 +9,19 @@
[class]="{ [class]="{
'cursor-default': option.value === optionFormControl.value, 'cursor-default': option.value === optionFormControl.value,
'cursor-pointer': 'cursor-pointer':
!isLoading() && option.value !== optionFormControl.value !isDisabled() &&
!isLoading() &&
option.value !== optionFormControl.value
}" }"
[disabled]="isLoading()" [disabled]="isDisabled() || isLoading()"
[title]="option.title ?? ''"
[value]="option.value" [value]="option.value"
>{{ option.label }}</mat-radio-button
> >
@if (option.iconName) {
<ion-icon class="d-block" [name]="option.iconName" />
} @else {
<span>{{ option.label }}</span>
}
</mat-radio-button>
} }
</mat-radio-group> </mat-radio-group>

16
libs/ui/src/lib/toggle/toggle.component.scss

@ -9,6 +9,14 @@
background-color: rgba(var(--dark-dividers)); background-color: rgba(var(--dark-dividers));
} }
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--dark-disabled-text));
}
}
}
::ng-deep { ::ng-deep {
.mdc-radio { .mdc-radio {
display: none; display: none;
@ -31,6 +39,14 @@
border: 1px solid rgba(var(--light-disabled-text)); border: 1px solid rgba(var(--light-disabled-text));
} }
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--light-disabled-text));
}
}
}
::ng-deep { ::ng-deep {
label { label {
color: rgba(var(--light-primary-text), 1); color: rgba(var(--light-primary-text), 1);

43
libs/ui/src/lib/toggle/toggle.component.stories.ts

@ -1,17 +1,22 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { GfToggleComponent } from './toggle.component'; import { GfToggleComponent } from './toggle.component';
addIcons({ gridOutline, reorderFourOutline });
export default { export default {
title: 'Toggle', title: 'Toggle',
component: GfToggleComponent, component: GfToggleComponent,
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
imports: [CommonModule, MatRadioModule, ReactiveFormsModule] imports: [CommonModule, IonIcon, MatRadioModule, ReactiveFormsModule]
}) })
] ]
} as Meta<GfToggleComponent>; } as Meta<GfToggleComponent>;
@ -21,6 +26,22 @@ type Story = StoryObj<GfToggleComponent>;
export const Default: Story = { export const Default: Story = {
args: { args: {
defaultValue: '1d', defaultValue: '1d',
isDisabled: false,
isLoading: false,
options: [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
]
}
};
export const Disabled: Story = {
args: {
defaultValue: '1d',
isDisabled: true,
isLoading: false, isLoading: false,
options: [ options: [
{ label: 'Today', value: '1d' }, { label: 'Today', value: '1d' },
@ -31,3 +52,23 @@ export const Default: Story = {
] ]
} }
}; };
export const WithIcons: Story = {
args: {
defaultValue: 'TABLE',
isDisabled: false,
isLoading: false,
options: [
{
iconName: 'reorder-four-outline',
title: 'Table',
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: 'Chart',
value: 'CHART'
}
]
}
};

6
libs/ui/src/lib/toggle/toggle.component.ts

@ -1,4 +1,4 @@
import { ToggleOption } from '@ghostfolio/common/interfaces'; import { ToggleOption } from '@ghostfolio/common/types';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -9,16 +9,18 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatRadioModule, ReactiveFormsModule], imports: [IonIcon, MatRadioModule, ReactiveFormsModule],
selector: 'gf-toggle', selector: 'gf-toggle',
styleUrls: ['./toggle.component.scss'], styleUrls: ['./toggle.component.scss'],
templateUrl: './toggle.component.html' templateUrl: './toggle.component.html'
}) })
export class GfToggleComponent { export class GfToggleComponent {
public readonly defaultValue = input.required<string>(); public readonly defaultValue = input.required<string>();
public readonly isDisabled = input<boolean>(false);
public readonly isLoading = input<boolean>(false); public readonly isLoading = input<boolean>(false);
public readonly options = input<ToggleOption[]>([]); public readonly options = input<ToggleOption[]>([]);

54
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.44.0", "version": "3.47.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.44.0", "version": "3.47.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "8.1.2", "@bull-board/api": "8.6.0",
"@bull-board/express": "8.1.2", "@bull-board/express": "8.6.0",
"@bull-board/nestjs": "8.1.2", "@bull-board/nestjs": "8.6.0",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
@ -3550,25 +3550,35 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "8.1.2", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.1.2.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.6.0.tgz",
"integrity": "sha512-6NGYCIRhHJmmoCwAFA1z4lPpR/D/BdOjyERd2MMeG4Samu05FSjzKKdN55S3ga30EcXeENfYzmNn6IDmVz5OTg==", "integrity": "sha512-tJLkYszUJhvDzTexLT+6/OEUYCWrR5QBKXk+SNlNsxHw8WClSRFEirXMrKwrei3LlMVxPV5lvbBOC7LNNymibQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "8.1.2" "@bull-board/ui": "8.6.0",
"bull": "^4.16.5",
"bullmq": "^5.79.2 || ^6.0.0"
},
"peerDependenciesMeta": {
"bull": {
"optional": true
},
"bullmq": {
"optional": true
}
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "8.1.2", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-8.1.2.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-8.6.0.tgz",
"integrity": "sha512-IldpQLXlezJRzk1BVNJjH+Oi3NBRcBopq9bI2ndLs0R+ySK1ux+TtGDUW+eX2fl0wEHwSpuUgPBAJID695zohA==", "integrity": "sha512-R2regQ3HOEOm5rtEmzy67qZsJ8burOaxIBUOiOhkzwuy8LVFQ4dFk0SpmYtUv+d/8m/YQVsL1b/6zCn3AyrpSg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "8.1.2", "@bull-board/api": "8.6.0",
"@bull-board/ui": "8.1.2", "@bull-board/ui": "8.6.0",
"ejs": "^6.0.1", "ejs": "^6.0.1",
"express": "^5.2.1" "express": "^5.2.1"
} }
@ -3586,12 +3596,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "8.1.2", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-8.1.2.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-8.6.0.tgz",
"integrity": "sha512-7sgESSitxULFSxuWIYx6zUyCgQIVFtw+s5ezGFvdh3/s2dwP2NoOQ76xJn+16cXh0EAFM+rN1f05BAtc0aLlUQ==", "integrity": "sha512-Ag2AQ7W2LgxFwXxQb9sasgc3SjHjUqD5CKQzsI/vXOFcAriUwlfowWb29C3oBap1fcJQZiJfPHB1ObT1ZQJLoA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^8.1.2", "@bull-board/api": "^8.6.0",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -3600,12 +3610,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "8.1.2", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.1.2.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.6.0.tgz",
"integrity": "sha512-gC5u9XUSiRile4/VC+WHXtaPvfZV4Mrrz8MSvPe32Hjl2MXStfSuEOb+9Tw/WMpNSZ442tGpFZLz0RDh1Yo3jg==", "integrity": "sha512-Qb/HYNU2JhRYKoyeJhXgeu1ZL4VK5RANmLC/cpzbtZPz5b9b260d0J0Af0L0zGdP6ORYg2XpPQKwrIy7n96AwQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "8.1.2" "@bull-board/api": "8.6.0"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.44.0", "version": "3.47.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",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "8.1.2", "@bull-board/api": "8.6.0",
"@bull-board/express": "8.1.2", "@bull-board/express": "8.6.0",
"@bull-board/nestjs": "8.1.2", "@bull-board/nestjs": "8.6.0",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",

13
prisma/migrations/20260808120000_added_draft_tag_to_order/migration.sql

@ -0,0 +1,13 @@
-- Create the "DRAFT" tag if it does not exist yet
INSERT INTO "Tag" ("id", "name")
VALUES ('0c077abd-eca2-4cbb-818c-6cefbf2d169a', 'DRAFT')
ON CONFLICT DO NOTHING;
-- Migrate activities with "isDraft" to the "DRAFT" tag
INSERT INTO "_OrderToTag" ("A", "B")
SELECT
"id",
'0c077abd-eca2-4cbb-818c-6cefbf2d169a'
FROM "Order"
WHERE "isDraft" = true
ON CONFLICT DO NOTHING;

1
prisma/schema.prisma

@ -183,6 +183,7 @@ model Order {
date DateTime date DateTime
fee Float fee Float
id String @id @default(uuid()) id String @id @default(uuid())
/// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead
isDraft Boolean @default(false) isDraft Boolean @default(false)
quantity Float quantity Float
symbolProfileId String symbolProfileId String

4
prisma/seed.mts

@ -10,6 +10,10 @@ const prisma = new PrismaClient({ adapter });
async function main() { async function main() {
await prisma.tag.createMany({ await prisma.tag.createMany({
data: [ data: [
{
id: '0c077abd-eca2-4cbb-818c-6cefbf2d169a',
name: 'DRAFT'
},
{ {
id: '4452656d-9fa4-4bd0-ba38-70492e31d180', id: '4452656d-9fa4-4bd0-ba38-70492e31d180',
name: 'EMERGENCY_FUND' name: 'EMERGENCY_FUND'

Loading…
Cancel
Save