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/),
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
@ -14,10 +48,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 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 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

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

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

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

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
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 { 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';
@ -287,9 +288,9 @@ export class ActivitiesController {
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
// (not MANUAL) and not draft
// (not MANUAL) and the date is not in the future
this.dataGatheringService.gatherSymbols({
dataGatheringItems: [
{
@ -369,6 +370,7 @@ export class ActivitiesController {
}),
user: { connect: { id: this.request.user.id } }
},
originalDate: originalActivity.date,
userId: this.request.user.id,
where: {
id

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

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

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 { PlatformService } from '@ghostfolio/api/app/platform/platform.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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,6 +14,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
@ -22,6 +24,7 @@ import {
} from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
@ -41,7 +44,7 @@ import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js';
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 { 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[] = [];
for (const activity of activitiesExtendedWithErrors) {
@ -775,6 +783,13 @@ export class ImportService {
});
if (isDryRun) {
const previewTags = getTagsWithDraftTag({
date,
draftTag,
type,
tags: validatedTags
});
order = {
comment,
currency,
@ -788,7 +803,7 @@ export class ImportService {
accountUserId: undefined,
createdAt: new Date(),
id: randomUUID(),
isDraft: isAfter(date, endOfToday()),
isDraft: isDraftActivity({ tags: previewTags }),
SymbolProfile: {
assetClass,
assetSubClass,
@ -817,7 +832,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
tags: validatedTags,
tags: previewTags,
updatedAt: new Date(),
userId: user.id
};

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

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

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

@ -35,6 +35,7 @@ import {
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
@ -195,7 +196,7 @@ export class UserService {
subscription.type === SubscriptionType.Basic
) {
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';
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;
}

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 countriesMapping = {
'Czech Republic': 'CZ',
'Republic of Korea': 'KR',
'Russian Federation': 'RU',
Turkey: 'TR',
@ -125,13 +126,14 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
response.countries.push({
code: getCountryCodeByName({
const code = getCountryCodeByName({
name,
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());
response.countries = etfCountryWeightings
.filter(({ country: countryName }) => {
return countryName.toLowerCase() !== 'other';
})
.map(({ country: countryName, weightPercentage }) => {
return {
code: getCountryCodeByName({
@ -180,6 +177,9 @@ export class FinancialModelingPrepService
}),
weight: parseFloat(`${weightPercentage}`) / 100
};
})
.filter(({ code }) => {
return !!code;
});
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 { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { HttpException, Injectable } from '@nestjs/common';
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))
.subscribe(() => {
this.initialize();
this.refreshUser();
});
}
@ -195,6 +197,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
this.refreshUser();
});
}
@ -413,4 +417,11 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchChart();
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)
)
.subscribe(({ price }) => {
const currency = this.assetProfileForm.controls.currency.value;
this.notificationService.alert({
title:
$localize`The current market price is` +
' ' +
price +
' ' +
this.assetProfileForm.controls.currency.value
title: `${$localize`The current market price is`} ${price}${
currency ? ` ${currency}` : ''
}`
});
});
}

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

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

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

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

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

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

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

@ -1,9 +1,3 @@
:host {
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,
InfoItem,
MarketDataOfMarketsResponse,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
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 { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index';
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -30,8 +31,9 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroup,
NonNullableFormBuilder,
FormsModule,
ReactiveFormsModule,
Validators
} from '@angular/forms';
@ -58,7 +60,7 @@ import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
GfCurrencySelectorComponent,
GfValueComponent,
IonIcon,
MatButtonModule,
@ -77,7 +79,12 @@ import { catchError } from 'rxjs/operators';
})
export class GfUserAccountSettingsComponent implements OnInit {
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 readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
@ -129,9 +136,8 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly webAuthnService = inject(WebAuthnService);
public constructor() {
const { baseCurrency, currencies } = this.dataService.fetchInfo();
const { currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies;
this.userService.stateChanged
@ -182,6 +188,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
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) {
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 });
}

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

@ -3,25 +3,17 @@
<div class="row">
<div class="col">
<div class="d-flex py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container>
</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-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="
onChangeUserSetting('baseCurrency', $event.value)
"
>
@for (currency of currencies; track currency) {
<mat-option [value]="currency">{{ currency }}</mat-option>
}
</mat-select>
<gf-currency-selector
formControlName="baseCurrency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
</div>
@ -214,7 +206,7 @@
</mat-form-field>
</div>
</div>
</form>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<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 { UserService } from '@ghostfolio/client/services/user/user.service';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -30,13 +22,10 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Tag } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs';
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 { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@ -54,10 +43,13 @@ export class GfAccountsPageComponent implements OnInit {
protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null;
protected readonly internalRoutes = internalRoutes;
protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0;
protected user: User;
private isInitialFetch = true;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
@ -79,26 +71,7 @@ export class GfAccountsPageComponent implements OnInit {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) {
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']) {
if (params['transferBalanceDialog']) {
this.openTransferBalanceDialog();
}
});
@ -130,12 +103,12 @@ export class GfAccountsPageComponent implements OnInit {
this.user.permissions,
permissions.updateAccount
);
this.fetchAccounts();
}
this.changeDetectorRef.markForCheck();
});
this.fetchAccounts();
}
protected onDeleteAccount(aId: string) {
@ -149,8 +122,6 @@ export class GfAccountsPageComponent implements OnInit {
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.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() {
this.dataService
.fetchAccounts()
@ -182,149 +147,21 @@ export class GfAccountsPageComponent implements OnInit {
this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency;
this.totalValueInBaseCurrency = totalValueInBaseCurrency;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
}
if (
this.accounts?.length <= 0 &&
this.hasPermissionToCreateAccount &&
this.isInitialFetch
) {
void this.router.navigate(
internalRoutes.accounts.subRoutes.create.routerLink
);
}
private openUpdateAccountDialog({
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.isInitialFetch = false;
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
);
}
private openTransferBalanceDialog() {

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

@ -15,7 +15,6 @@
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
(transferBalance)="onTransferBalance()"
/>
</div>
@ -26,6 +25,10 @@
hasPermissionToCreateAccount &&
!user.settings.isRestrictedView
) {
<gf-fab [queryParams]="{ createDialog: true }" />
<gf-fab
[routerLink]="internalRoutes.accounts.subRoutes.create.routerLink"
/>
}
</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 { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component';
import { GfAccountsPageComponent } from './accounts-page.component';
const { create, detail, update } = internalRoutes.accounts.subRoutes;
export const routes: Routes = [
{
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,
path: '',
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 { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -92,7 +93,13 @@ export class GfCreateOrUpdateAccountDialogComponent {
);
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 {
...tag,
name: translate(tag.name)

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

@ -27,7 +27,7 @@
<div class="row">
<div class="col">
<mat-card appearance="outlined">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="row">
<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.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createActivity);
hasPermission(this.user.permissions, permissions.createActivity) &&
!this.user.settings?.isRestrictedView;
this.hasPermissionToDeleteActivity =
!this.hasImpersonationId &&
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 { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, 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';
@ -16,8 +18,15 @@ 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 } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { EMPTY, Observable, of, Subject } from 'rxjs';
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 { 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 {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
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 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
? this.dataService.fetchActivity(activityId)
: of(undefined);
this.userService
.get()
.pipe(
return this.userService.get().pipe(
switchMap((user) => {
return activity$.pipe(
map((activity) => {
@ -57,15 +80,22 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
})
);
}),
catchError(() => {
this.navigateBack();
return EMPTY;
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
.subscribe(({ activity, user }) => {
if (mode === 'update') {
if (!activity) {
if (
!activity ||
!hasPermission(user?.permissions, permissions.updateActivity) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
@ -82,6 +112,16 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
return;
}
// Cloning creates a new activity as well
if (
!hasPermission(user?.permissions, permissions.createActivity) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
this.openDialog({
user,
activity: {
@ -96,7 +136,6 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
},
isUpdate: false
});
}
});
}
@ -105,6 +144,23 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
// 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() {
@ -124,7 +180,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
}) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open<
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
@ -137,9 +193,11 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) {
this.navigateBack();

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

@ -13,7 +13,6 @@ import {
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPosition,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
import {
@ -21,7 +20,11 @@ import {
hasReadRestrictedAccessPermission,
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 { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
@ -51,10 +54,11 @@ import { IonIcon } from '@ionic/angular/standalone';
import { SymbolProfile } from '@prisma/client';
import { addIcons } from 'ionicons';
import { copyOutline, ellipsisVertical } from 'ionicons/icons';
import { isNumber, sortBy } from 'lodash';
import { isNumber, keyBy, sortBy, union } from 'lodash';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -254,32 +258,51 @@ export class GfAnalysisPageComponent implements OnInit {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
this.dataService
.fetchDividends({
forkJoin({
dividends: this.dataService.fetchDividends({
filters: this.userService.getFilters(),
groupBy: this.mode(),
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))
.subscribe(({ dividends }) => {
this.dividendsByGroup = dividends;
.subscribe(
({
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.streaks = streaks;
this.unitCurrentStreak =
this.mode() === 'year'
? this.streaks?.currentStreak === 1
@ -288,6 +311,7 @@ export class GfAnalysisPageComponent implements OnInit {
: this.streaks?.currentStreak === 1
? translate('MONTH')
: translate('MONTHS');
this.unitLongestStreak =
this.mode() === 'year'
? this.streaks?.longestStreak === 1
@ -297,10 +321,12 @@ export class GfAnalysisPageComponent implements OnInit {
? translate('MONTH')
: translate('MONTHS');
this.isLoadingDividendTimelineChart = false;
this.isLoadingInvestmentTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
}
);
}
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;
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_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_IDS_SYSTEM = [
TAG_ID_DEMO,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS
];

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

@ -43,6 +43,7 @@ import {
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
TAG_IDS_SYSTEM
} 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) {
if (aCurrency === 'USD') {
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 { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface';
import type { ToggleOption } from './toggle-option.interface';
import type { UserItem } from './user-item.interface';
import type { UserSettings } from './user-settings.interface';
import type { User } from './user.interface';
@ -191,7 +190,6 @@ export {
SymbolItem,
SymbolMetrics,
SystemMessage,
ToggleOption,
User,
UserItem,
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: {
path: '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`
},
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 { SectorName } from './sector-name.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 { ViewMode } from './view-mode.type';
@ -52,6 +53,7 @@ export type {
RequestWithUser,
SectorName,
SubscriptionOfferKey,
ToggleOption,
UserWithSettings,
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" />
</button>
<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">
<ion-icon class="mr-2" name="wallet-outline" />
<span><ng-container i18n>View Details</ng-container>...</span>
</span>
</button>
<button mat-menu-item (click)="onUpdateAccount(element)">
</a>
}
<a
mat-menu-item
[routerLink]="accountDialogRouterLinks().get(element.id)?.update"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
</span>
</button>
</a>
<hr class="m-0" />
<button
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 { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { provideRouter, RouterModule } from '@angular/router';
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -92,6 +92,9 @@ export default {
title: 'Accounts Table',
component: GfAccountsTableComponent,
decorators: [
applicationConfig({
providers: [provideRouter([])]
}),
moduleMetadata({
imports: [
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 = {
args: {
accounts,

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

@ -4,6 +4,7 @@ import {
getLowercase,
isAccountExcluded
} from '@ghostfolio/common/helper';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
@ -71,11 +72,28 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>();
public readonly accountToUpdate = output<AccountWithValue>();
public readonly transferBalance = output<void>();
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 displayedColumns = computed(() => {
@ -159,9 +177,9 @@ export class GfAccountsTableComponent {
protected onOpenAccountDetailDialog(accountId: string) {
if (this.hasPermissionToOpenDetails()) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
void this.router.navigate(
internalRoutes.accounts.subRoutes.detail.routerLink(accountId)
);
}
}
@ -174,8 +192,4 @@ export class GfAccountsTableComponent {
protected onTransferBalance() {
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>
<div class="align-items-center d-flex text-nowrap">
<span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) {
@if (isDraftActivity(element)) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
}
</div>
@ -513,7 +513,7 @@
</a>
<button
mat-menu-item
[disabled]="!element.isDraft"
[disabled]="!isDraftActivity(element)"
(click)="onExportDraft(element.id)"
>
<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
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper';
import {
getLocale,
isAccountExcluded,
isDraftActivity
} from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
@ -138,6 +142,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public activityTypesTranslationMap = new Map<ActivityType, string>();
public hasDrafts = false;
public hasErrors = false;
public isDraftActivity = isDraftActivity;
public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]);
@ -277,7 +282,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
return (
this.hasPermissionToOpenDetails &&
this.isExcludedFromAnalysis(activity) === false &&
activity.isDraft === false &&
isDraftActivity(activity) === false &&
['BUY', 'DIVIDEND', 'SELL'].includes(activity.type)
);
}
@ -357,7 +362,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.exportDrafts.emit(
this.dataSource()
?.filteredData.filter((activity) => {
return activity.isDraft;
return isDraftActivity(activity);
})
.map((activity) => {
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() {
if (this.item?.mode === SearchMode.ACCOUNT) {
this.queryParams = {
accountDetailDialog: true,
accountId: this.item.id
};
this.routerLink = internalRoutes.accounts.routerLink;
this.queryParams = {};
this.routerLink = this.item.routerLink;
} else if (this.item?.mode === SearchMode.ASSET_PROFILE) {
this.queryParams = {
assetProfileDialog: true,

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

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

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

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

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

@ -13,6 +13,7 @@
<mat-autocomplete
#currencyAutocomplete="matAutocomplete"
(closed)="onPanelClosed()"
(optionSelected)="onUpdateCurrency($event)"
>
@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 formField = inject(MAT_FORM_FIELD);
private readonly input = viewChild.required(MatInput);
private lastSelectedCurrency: string | null = null;
public constructor(
public override readonly _elementRef: ElementRef,
@ -110,9 +111,15 @@ export class GfCurrencySelectorComponent
return !this.control.value;
}
public override get value() {
return super.value;
}
public override set value(value: string | null) {
this.control.setValue(value);
super.value = value;
this.lastSelectedCurrency = value;
}
public focus() {
@ -124,10 +131,6 @@ export class GfCurrencySelectorComponent
}
public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
const formGroup = this.formGroupDirective.form;
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> }) {
super.value = option.value;
this.lastSelectedCurrency = option.value;
}
private filter(value: string) {

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

@ -7,5 +7,5 @@
(error)="onError()"
/>
} @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;
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: {
hasPlaceholder: true,
size: 'large'
size: 'large',
tooltip: 'Unknown',
url: 'https://unknown.ghostfol.io'
}
};
export const PlaceholderOnError: Story = {
export const Placeholder: Story = {
args: {
hasPlaceholder: true,
size: 'large',
tooltip: 'Unknown',
url: 'https://unknown.ghostfol.io'
size: 'large'
}
};

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_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_SOURCE: $localize`Data Source`,
DRAFT: $localize`Draft`,
EMERGENCY_FUND: $localize`Emergency Fund`,
EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`,
Global: $localize`Global`,

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

@ -9,7 +9,12 @@ import {
Input,
OnDestroy
} 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 { Subject } from 'rxjs';
@ -23,6 +28,7 @@ export abstract class AbstractMatFormField<T>
@HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean;
public abstract readonly control: FormControl;
public errorState: boolean;
public focused = false;
public readonly stateChanges = new Subject<void>();
@ -156,6 +162,16 @@ export abstract class AbstractMatFormField<T>
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) {
this.value = value;
}

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

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

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

@ -9,11 +9,19 @@
[class]="{
'cursor-default': option.value === optionFormControl.value,
'cursor-pointer':
!isLoading() && option.value !== optionFormControl.value
!isDisabled() &&
!isLoading() &&
option.value !== optionFormControl.value
}"
[disabled]="isLoading()"
[disabled]="isDisabled() || isLoading()"
[title]="option.title ?? ''"
[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>

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

@ -9,6 +9,14 @@
background-color: rgba(var(--dark-dividers));
}
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--dark-disabled-text));
}
}
}
::ng-deep {
.mdc-radio {
display: none;
@ -31,6 +39,14 @@
border: 1px solid rgba(var(--light-disabled-text));
}
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--light-disabled-text));
}
}
}
::ng-deep {
label {
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 { ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } 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';
addIcons({ gridOutline, reorderFourOutline });
export default {
title: 'Toggle',
component: GfToggleComponent,
decorators: [
moduleMetadata({
imports: [CommonModule, MatRadioModule, ReactiveFormsModule]
imports: [CommonModule, IonIcon, MatRadioModule, ReactiveFormsModule]
})
]
} as Meta<GfToggleComponent>;
@ -21,6 +26,22 @@ type Story = StoryObj<GfToggleComponent>;
export const Default: Story = {
args: {
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,
options: [
{ 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 {
ChangeDetectionStrategy,
@ -9,16 +9,18 @@ import {
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatRadioModule, ReactiveFormsModule],
imports: [IonIcon, MatRadioModule, ReactiveFormsModule],
selector: 'gf-toggle',
styleUrls: ['./toggle.component.scss'],
templateUrl: './toggle.component.html'
})
export class GfToggleComponent {
public readonly defaultValue = input.required<string>();
public readonly isDisabled = input<boolean>(false);
public readonly isLoading = input<boolean>(false);
public readonly options = input<ToggleOption[]>([]);

54
package-lock.json

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

8
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.44.0",
"version": "3.47.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7",
"@bull-board/api": "8.1.2",
"@bull-board/express": "8.1.2",
"@bull-board/nestjs": "8.1.2",
"@bull-board/api": "8.6.0",
"@bull-board/express": "8.6.0",
"@bull-board/nestjs": "8.6.0",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@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
fee Float
id String @id @default(uuid())
/// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead
isDraft Boolean @default(false)
quantity Float
symbolProfileId String

4
prisma/seed.mts

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

Loading…
Cancel
Save