Browse Source

Merge branch 'task/account-table-comp-stories' of https://github.com/Raj-G07/ghostfolio into task/account-table-comp-stories

pull/5696/head
Raj Gupta 4 weeks ago
parent
commit
24cfd75858
  1. 30
      CHANGELOG.md
  2. 59
      apps/api/src/app/access/access.controller.ts
  3. 19
      apps/api/src/app/access/access.service.ts
  4. 19
      apps/api/src/app/access/update-access.dto.ts
  5. 18
      apps/api/src/app/admin/admin.controller.ts
  6. 1
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 10
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  9. 14
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  10. 4
      apps/client/src/app/app-routing.module.ts
  11. 12
      apps/client/src/app/components/access-table/access-table.component.html
  12. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  13. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  14. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss
  15. 51
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  16. 8
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html
  17. 1
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  18. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  19. 133
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  20. 77
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  21. 20
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  22. 42
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  23. 1
      apps/client/src/app/components/user-account-access/user-account-access.html
  24. 5
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  25. 9
      apps/client/src/app/directives/file-drop/file-drop.module.ts
  26. 15
      apps/client/src/app/pages/auth/auth-page-routing.module.ts
  27. 5
      apps/client/src/app/pages/auth/auth-page.component.ts
  28. 11
      apps/client/src/app/pages/auth/auth-page.module.ts
  29. 18
      apps/client/src/app/pages/auth/auth-page.routes.ts
  30. 22
      apps/client/src/app/pages/open/open-page-routing.module.ts
  31. 20
      apps/client/src/app/pages/open/open-page.component.ts
  32. 20
      apps/client/src/app/pages/open/open-page.module.ts
  33. 15
      apps/client/src/app/pages/open/open-page.routes.ts
  34. 10
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  35. 27
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  36. 4
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  37. 9
      apps/client/src/app/pipes/symbol/symbol.module.ts
  38. 5
      apps/client/src/app/pipes/symbol/symbol.pipe.ts
  39. 5
      apps/client/src/app/services/data.service.ts
  40. 3
      libs/common/src/lib/interfaces/fire-wealth.interface.ts
  41. 2
      libs/common/src/lib/interfaces/index.ts
  42. 3
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  43. 3
      libs/common/src/lib/permissions.ts
  44. 4
      libs/ui/src/lib/activities-filter/activities-filter.component.ts
  45. 471
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  46. 4
      libs/ui/src/lib/activities-table/activities-table.component.ts
  47. 4
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  48. 67
      libs/ui/src/lib/assistant/assistant.component.ts
  49. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  50. 4
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  51. 4
      libs/ui/src/lib/top-holdings/top-holdings.component.ts
  52. 4
      package-lock.json
  53. 2
      package.json

30
CHANGELOG.md

@ -5,6 +5,36 @@ 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
### Added
- Added support to edit a granted access (experimental)
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
### Changed
- Improved the spacing around the buttons in the holding detail dialog
- Refactored the auth page to standalone
## 2.206.0 - 2025-10-04
### Changed
- Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page
- Improved the usability of the assistant by preselecting the first search result
- Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog
- Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint
- Refactored the _Open Startup_ (`/open`) page to standalone
- Refactored the file drop directive to standalone
- Refactored the symbol pipe to standalone
### Fixed
- Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service
- Added the missing `CommonModule` import in the import activities dialog
## 2.205.0 - 2025-10-01 ## 2.205.0 - 2025-10-01
### Unreleased ### Unreleased

59
apps/api/src/app/access/access.controller.ts

@ -14,6 +14,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -39,7 +41,7 @@ export class AccessController {
include: { include: {
granteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
@ -103,9 +105,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id }); const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) { if (!originalAccess) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -116,4 +121,52 @@ export class AccessController {
id id
}); });
} }
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Body() data: UpdateAccessDto,
@Param('id') id: string
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
},
where: { id }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
} }

19
apps/api/src/app/access/access.service.ts

@ -20,14 +20,14 @@ export class AccessService {
} }
public async accesses(params: { public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number; skip?: number;
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({ return this.prismaService.access.findMany({
cursor, cursor,
@ -52,4 +52,17 @@ export class AccessService {
where where
}); });
} }
public async updateAccess({
data,
where
}: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({
data,
where
});
}
} }

19
apps/api/src/app/access/update-access.dto.ts

@ -0,0 +1,19 @@
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsUUID()
granteeUserId?: string;
@IsString()
id: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
permissions?: AccessPermission[];
}

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

@ -6,6 +6,7 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -22,6 +23,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
DateRange,
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -161,9 +163,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> { ): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange, new Date());
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return; return;
} }

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

@ -195,7 +195,6 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interest', 'interest',

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

@ -2092,9 +2092,13 @@ export class PortfolioService {
filteredValueInPercentage: netWorth filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber() ? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined, : undefined,
fireWealth: new Big(currentValueInBaseCurrency) fireWealth: {
.minus(emergencyFundHoldingsValueInBaseCurrency) today: {
.toNumber(), valueInBaseCurrency: new Big(currentValueInBaseCurrency)
.minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber()
}
},
grossPerformance: new Big(netPerformance).plus(fees).toNumber(), grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big( grossPerformanceWithCurrencyEffect: new Big(
netPerformanceWithCurrencyEffect netPerformanceWithCurrencyEffect

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

@ -161,7 +161,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
).then((res) => res.json()); ).then((res) => res.json());
if (etfInformation.website) { if (etfInformation?.website) {
response.url = etfInformation.website; response.url = etfInformation.website;
} }

14
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -94,17 +94,21 @@ export class DataGatheringService {
}); });
} }
public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public async gatherSymbol({ dataSource, date, symbol }: IDataGatheringItem) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter( const dataGatheringItems = (await this.getSymbolsMax())
(dataGatheringItem) => { .filter((dataGatheringItem) => {
return ( return (
dataGatheringItem.dataSource === dataSource && dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol dataGatheringItem.symbol === symbol
); );
} })
); .map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({ await this.gatherSymbols({
dataGatheringItems, dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH

4
apps/client/src/app/app-routing.module.ts

@ -42,7 +42,7 @@ const routes: Routes = [
{ {
path: internalRoutes.auth.path, path: internalRoutes.auth.path,
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule), import('./pages/auth/auth-page.routes').then((m) => m.routes),
title: internalRoutes.auth.title title: internalRoutes.auth.title
}, },
{ {
@ -94,7 +94,7 @@ const routes: Routes = [
{ {
path: publicRoutes.openStartup.path, path: publicRoutes.openStartup.path,
loadChildren: () => loadChildren: () =>
import('./pages/open/open-page.module').then((m) => m.OpenPageModule) import('./pages/open/open-page.routes').then((m) => m.routes)
}, },
{ {
path: internalRoutes.portfolio.path, path: internalRoutes.portfolio.path,

12
apps/client/src/app/components/access-table/access-table.component.html

@ -65,6 +65,14 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
@if (user?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
}
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)"> <button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -72,6 +80,10 @@
<span i18n>Copy link to clipboard</span> <span i18n>Copy link to clipboard</span>
</span> </span>
</button> </button>
}
@if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC'
) {
<hr class="my-0" /> <hr class="my-0" />
} }
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">

7
apps/client/src/app/components/access-table/access-table.component.ts

@ -23,6 +23,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
copyOutline, copyOutline,
createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
@ -53,6 +54,7 @@ export class GfAccessTableComponent implements OnChanges {
@Input() user: User; @Input() user: User;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();
@Output() accessToUpdate = new EventEmitter<string>();
public baseUrl = window.location.origin; public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; public dataSource: MatTableDataSource<Access>;
@ -65,6 +67,7 @@ export class GfAccessTableComponent implements OnChanges {
) { ) {
addIcons({ addIcons({
copyOutline, copyOutline,
createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
@ -112,4 +115,8 @@ export class GfAccessTableComponent implements OnChanges {
title: $localize`Do you really want to revoke this granted access?` title: $localize`Do you really want to revoke this granted access?`
}); });
} }
public onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId);
}
} }

4
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -79,7 +79,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
CommonModule, CommonModule,
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolModule, GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

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

@ -8,5 +8,9 @@
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
margin: 0 -0.5rem; margin: 0 -0.5rem;
} }
.button-container {
gap: 0.5rem;
}
} }
} }

51
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -424,33 +424,34 @@
(dataSource?.data.length > 0 && (dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true) data.hasPermissionToReportDataGlitch === true)
) { ) {
<hr />
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<hr /> <div class="button-container d-flex flex-wrap">
@if (data.hasPermissionToAccessAdminControl) { @if (data.hasPermissionToAccessAdminControl) {
<a <a
class="mr-2" mat-stroked-button
mat-stroked-button [queryParams]="{
[queryParams]="{ assetProfileDialog: true,
assetProfileDialog: true, dataSource: SymbolProfile?.dataSource,
dataSource: SymbolProfile?.dataSource, symbol: SymbolProfile?.symbol
symbol: SymbolProfile?.symbol }"
}" [routerLink]="routerLinkAdminControlMarketData"
[routerLink]="routerLinkAdminControlMarketData" (click)="onClose()"
(click)="onClose()" ><ion-icon class="mr-1" name="create-outline"></ion-icon
><ion-icon class="mr-1" name="create-outline"></ion-icon ><span i18n>Manage Asset Profile</span>...</a
><span i18n>Manage Asset Profile</span>...</a >
> }
} @if (
@if ( dataSource?.data.length > 0 &&
dataSource?.data.length > 0 && data.hasPermissionToReportDataGlitch === true
data.hasPermissionToReportDataGlitch === true ) {
) { <a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail" ><ion-icon class="mr-1" name="flag-outline"></ion-icon
><ion-icon class="mr-1" name="flag-outline"></ion-icon ><span i18n>Report Data Glitch</span>...</a
><span i18n>Report Data Glitch</span>...</a >
> }
} </div>
</div> </div>
</div> </div>
} }

8
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html

@ -12,7 +12,13 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (createWatchlistItemForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button

1
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -5,6 +5,7 @@ import {
export interface IRuleSettingsDialogParams { export interface IRuleSettingsDialogParams {
categoryName: string; categoryName: string;
locale: string;
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
} }

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,4 +1,5 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
@ -17,6 +18,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSliderModule MatSliderModule

133
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -5,28 +5,30 @@
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
) { ) {
<div class="w-100"> <div class="w-100">
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold range</ng-container>: <ng-container i18n>Threshold range</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMin | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMin }} [locale]="data.locale"
} [precision]="2"
- [value]="data.settings.thresholdMin"
@if (data.rule.configuration.threshold.unit === '%') { />
{{ data.settings.thresholdMax | percent: '1.2-2' }} <span class="mx-1">-</span>
} @else { <gf-value
{{ data.settings.thresholdMax }} [isPercent]="data.rule.configuration.threshold.unit === '%'"
} [locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
[max]="data.rule.configuration.threshold.max" [max]="data.rule.configuration.threshold.max"
@ -36,13 +38,12 @@
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> <input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> <input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
} @else { } @else {
@ -50,22 +51,23 @@
class="w-100" class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
> >
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold Min</ng-container>: <ng-container i18n>Threshold Min</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMin | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMin }} [locale]="data.locale"
} [precision]="2"
[value]="data.settings.thresholdMin"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
name="thresholdMin" name="thresholdMin"
@ -75,35 +77,35 @@
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> <input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
<div <div
class="w-100" class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
> >
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold Max</ng-container>: <ng-container i18n>Threshold Max</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMax | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMax }} [locale]="data.locale"
} [precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
name="thresholdMax" name="thresholdMax"
@ -113,13 +115,12 @@
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> <input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
} }

77
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
@ -8,7 +9,8 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy,
OnInit
} from '@angular/core'; } from '@angular/core';
import { import {
FormBuilder, FormBuilder,
@ -47,8 +49,11 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'], styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy { export class GfCreateOrUpdateAccessDialogComponent
implements OnDestroy, OnInit
{
public accessForm: FormGroup; public accessForm: FormGroup;
public mode: 'create' | 'update';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -59,14 +64,24 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService private notificationService: NotificationService
) {} ) {
this.mode = this.data.access?.id ? 'update' : 'create';
}
public ngOnInit() { public ngOnInit() {
const isPublic = this.data.access.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias], alias: [this.data.access.alias],
granteeUserId: [
this.data.access.grantee,
isPublic ? null : Validators.required
],
permissions: [this.data.access.permissions[0], Validators.required], permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required], type: [
granteeUserId: [this.data.access.grantee, Validators.required] { disabled: this.mode === 'update', value: this.data.access.type },
Validators.required
]
}); });
this.accessForm.get('type').valueChanges.subscribe((accessType) => { this.accessForm.get('type').valueChanges.subscribe((accessType) => {
@ -77,6 +92,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
granteeUserIdControl.setValidators(Validators.required); granteeUserIdControl.setValidators(Validators.required);
} else { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]); permissionsControl.setValue(this.data.access.permissions[0]);
} }
@ -91,6 +107,19 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
} }
public async onSubmit() { public async onSubmit() {
if (this.mode === 'create') {
await this.createAccess();
} else {
await this.updateAccess();
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async createAccess() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId').value,
@ -126,8 +155,40 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
} }
} }
public ngOnDestroy() { private async updateAccess() {
this.unsubscribeSubject.next(); const access: UpdateAccessDto = {
this.unsubscribeSubject.complete(); alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value]
};
try {
await validateObjectForForm({
classDto: UpdateAccessDto,
form: this.accessForm,
object: access
});
this.dataService
.putAccess(access)
.pipe(
catchError(({ status }) => {
if (status.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not update access.`
});
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
} }
} }

20
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -4,7 +4,13 @@
(keyup.enter)="accessForm.valid && onSubmit()" (keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 i18n mat-dialog-title>Grant access</h1> <h1 mat-dialog-title>
@if (mode === 'create') {
<span i18n>Grant access</span>
} @else {
<span i18n>Edit access</span>
}
</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -66,9 +72,17 @@
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!(accessForm.dirty && accessForm.valid)" [disabled]="
mode === 'create'
? !(accessForm.dirty && accessForm.valid)
: !accessForm.valid
"
> >
<ng-container i18n>Save</ng-container> @if (mode === 'create') {
<ng-container i18n>Save</ng-container>
} @else {
<ng-container i18n>Update</ng-container>
}
</button> </button>
</div> </div>
</form> </form>

42
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -115,6 +115,8 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateAccessDialog(); this.openCreateAccessDialog();
} else if (params['editDialog'] && params['accessId']) {
this.openUpdateAccessDialog(params['accessId']);
} }
}); });
@ -173,6 +175,12 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
public onUpdateAccess(aId: string) {
this.router.navigate([], {
queryParams: { accessId: aId, editDialog: true }
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
@ -200,6 +208,40 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
private openUpdateAccessDialog(accessId: string) {
const access = this.accessesGive?.find(({ id }) => {
return id === accessId;
});
if (!access) {
console.log('Could not find access.');
return;
}
const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialogComponent, {
data: {
access: {
alias: access.alias,
id: access.id,
grantee: access.grantee === 'Public' ? null : access.grantee,
permissions: access.permissions,
type: access.type
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.update();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() { private update() {
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { this.accessesGet = this.user.access.map(({ alias, id, permissions }) => {
return { return {

1
apps/client/src/app/components/user-account-access/user-account-access.html

@ -64,6 +64,7 @@
[showActions]="hasPermissionToDeleteAccess" [showActions]="hasPermissionToDeleteAccess"
[user]="user" [user]="user"
(accessDeleted)="onDeleteAccess($event)" (accessDeleted)="onDeleteAccess($event)"
(accessToUpdate)="onUpdateAccess($event)"
/> />
@if (hasPermissionToCreateAccess) { @if (hasPermissionToCreateAccess) {
<div class="fab-container"> <div class="fab-container">

5
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,10 +1,9 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({ @Directive({
selector: '[gfFileDrop]', selector: '[gfFileDrop]'
standalone: false
}) })
export class FileDropDirective { export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>(); @Output() filesDropped = new EventEmitter<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) {

9
apps/client/src/app/directives/file-drop/file-drop.module.ts

@ -1,9 +0,0 @@
import { NgModule } from '@angular/core';
import { FileDropDirective } from './file-drop.directive';
@NgModule({
declarations: [FileDropDirective],
exports: [FileDropDirective]
})
export class GfFileDropModule {}

15
apps/client/src/app/pages/auth/auth-page-routing.module.ts

@ -1,15 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthPageComponent } from './auth-page.component';
const routes: Routes = [
{ component: AuthPageComponent, path: '' },
{ component: AuthPageComponent, path: ':jwt' }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AuthPageRoutingModule {}

5
apps/client/src/app/pages/auth/auth-page.component.ts

@ -11,11 +11,10 @@ import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-auth-page', selector: 'gf-auth-page',
templateUrl: './auth-page.html',
styleUrls: ['./auth-page.scss'], styleUrls: ['./auth-page.scss'],
standalone: false templateUrl: './auth-page.html'
}) })
export class AuthPageComponent implements OnDestroy, OnInit { export class GfAuthPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(

11
apps/client/src/app/pages/auth/auth-page.module.ts

@ -1,11 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { AuthPageRoutingModule } from './auth-page-routing.module';
import { AuthPageComponent } from './auth-page.component';
@NgModule({
declarations: [AuthPageComponent],
imports: [AuthPageRoutingModule, CommonModule]
})
export class AuthPageModule {}

18
apps/client/src/app/pages/auth/auth-page.routes.ts

@ -0,0 +1,18 @@
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfAuthPageComponent } from './auth-page.component';
export const routes: Routes = [
{
component: GfAuthPageComponent,
path: '',
title: internalRoutes.auth.title
},
{
component: GfAuthPageComponent,
path: ':jwt',
title: internalRoutes.auth.title
}
];

22
apps/client/src/app/pages/open/open-page-routing.module.ts

@ -1,22 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OpenPageComponent } from './open-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: OpenPageComponent,
path: '',
title: publicRoutes.openStartup.title
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class OpenPageRoutingModule {}

20
apps/client/src/app/pages/open/open-page.component.ts

@ -1,18 +1,28 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Statistics, User } from '@ghostfolio/common/interfaces'; import { Statistics, User } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
OnInit
} from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule, GfValueComponent, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-open-page', selector: 'gf-open-page',
styleUrls: ['./open-page.scss'], styleUrls: ['./open-page.scss'],
templateUrl: './open-page.html', templateUrl: './open-page.html'
standalone: false
}) })
export class OpenPageComponent implements OnDestroy, OnInit { export class GfOpenPageComponent implements OnDestroy, OnInit {
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;

20
apps/client/src/app/pages/open/open-page.module.ts

@ -1,20 +0,0 @@
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { OpenPageRoutingModule } from './open-page-routing.module';
import { OpenPageComponent } from './open-page.component';
@NgModule({
declarations: [OpenPageComponent],
imports: [
CommonModule,
GfValueComponent,
MatCardModule,
OpenPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class OpenPageModule {}

15
apps/client/src/app/pages/open/open-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfOpenPageComponent } from './open-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfOpenPageComponent,
path: '',
title: publicRoutes.openStartup.title
}
];

10
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -4,8 +4,8 @@ import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component'; import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component'; import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module'; import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
@ -15,6 +15,7 @@ import {
StepperOrientation, StepperOrientation,
StepperSelectionEvent StepperSelectionEvent
} from '@angular/cdk/stepper'; } from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -59,11 +60,12 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
CommonModule,
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfFileDropModule, GfFileDropDirective,
GfSymbolModule, GfSymbolPipe,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

27
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,7 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
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 { User } from '@ghostfolio/common/interfaces'; import { FireWealth, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class GfFirePageComponent implements OnDestroy, OnInit { export class GfFirePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public fireWealth: Big; public fireWealth: FireWealth;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
@ -55,17 +55,24 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
.fetchPortfolioDetails() .fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.fireWealth = summary.fireWealth this.fireWealth = {
? new Big(summary.fireWealth) today: {
: new Big(0); valueInBaseCurrency: summary.fireWealth
? summary.fireWealth.today.valueInBaseCurrency
: 0
}
};
if (this.user.subscription?.type === 'Basic') { if (this.user.subscription?.type === 'Basic') {
this.fireWealth = new Big(10000); this.fireWealth = {
today: {
valueInBaseCurrency: 10000
}
};
} }
this.withdrawalRatePerYear = this.fireWealth.mul( this.withdrawalRatePerYear = Big(
this.user.settings.safeWithdrawalRate this.fireWealth.today.valueInBaseCurrency
); ).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12); this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);

4
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -14,7 +14,7 @@
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()" [fireWealth]="fireWealth?.today.valueInBaseCurrency"
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings !hasImpersonationId && hasPermissionToUpdateUserSettings
" "
@ -100,7 +100,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
[value]="fireWealth?.toNumber()" [value]="fireWealth?.today.valueInBaseCurrency"
/> />
</span> </span>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>

9
apps/client/src/app/pipes/symbol/symbol.module.ts

@ -1,9 +0,0 @@
import { NgModule } from '@angular/core';
import { SymbolPipe } from './symbol.pipe';
@NgModule({
declarations: [SymbolPipe],
exports: [SymbolPipe]
})
export class GfSymbolModule {}

5
apps/client/src/app/pipes/symbol/symbol.pipe.ts

@ -3,10 +3,9 @@ import { prettifySymbol } from '@ghostfolio/common/helper';
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ @Pipe({
name: 'gfSymbol', name: 'gfSymbol'
standalone: false
}) })
export class SymbolPipe implements PipeTransform { export class GfSymbolPipe implements PipeTransform {
public transform(aSymbol: string) { public transform(aSymbol: string) {
return prettifySymbol(aSymbol); return prettifySymbol(aSymbol);
} }

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

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto'; import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
@ -792,6 +793,10 @@ export class DataService {
return this.http.post('/api/v1/watchlist', watchlistItem); return this.http.post('/api/v1/watchlist', watchlistItem);
} }
public putAccess(aAccess: UpdateAccessDto) {
return this.http.put<Access>(`/api/v1/access/${aAccess.id}`, aAccess);
}
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount); return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
} }

3
libs/common/src/lib/interfaces/fire-wealth.interface.ts

@ -0,0 +1,3 @@
export interface FireWealth {
today: { valueInBaseCurrency: number };
}

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

@ -19,6 +19,7 @@ import type { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'
import type { Export } from './export.interface'; import type { Export } from './export.interface';
import type { FilterGroup } from './filter-group.interface'; import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface'; import type { Filter } from './filter.interface';
import type { FireWealth } from './fire-wealth.interface';
import type { HistoricalDataItem } from './historical-data-item.interface'; import type { HistoricalDataItem } from './historical-data-item.interface';
import type { HoldingWithParents } from './holding-with-parents.interface'; import type { HoldingWithParents } from './holding-with-parents.interface';
import type { Holding } from './holding.interface'; import type { Holding } from './holding.interface';
@ -104,6 +105,7 @@ export {
Export, Export,
Filter, Filter,
FilterGroup, FilterGroup,
FireWealth,
HistoricalDataItem, HistoricalDataItem,
HistoricalResponse, HistoricalResponse,
Holding, Holding,

3
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -1,3 +1,4 @@
import { FireWealth } from './fire-wealth.interface';
import { PortfolioPerformance } from './portfolio-performance.interface'; import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance { export interface PortfolioSummary extends PortfolioPerformance {
@ -16,7 +17,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
fees: number; fees: number;
filteredValueInBaseCurrency?: number; filteredValueInBaseCurrency?: number;
filteredValueInPercentage?: number; filteredValueInPercentage?: number;
fireWealth: number; fireWealth: FireWealth;
grossPerformance: number; grossPerformance: number;
grossPerformanceWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number;
interest: number; interest: number;

3
libs/common/src/lib/permissions.ts

@ -49,6 +49,7 @@ export const permissions = {
syncDemoUserAccount: 'syncDemoUserAccount', syncDemoUserAccount: 'syncDemoUserAccount',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
updateAccess: 'updateAccess',
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',
updateMarketData: 'updateMarketData', updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
@ -93,6 +94,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readTags, permissions.readTags,
permissions.readWatchlist, permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketData, permissions.updateMarketData,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
@ -133,6 +135,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readWatchlist, permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateOrder, permissions.updateOrder,

4
libs/ui/src/lib/activities-filter/activities-filter.component.ts

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { Filter, FilterGroup } from '@ghostfolio/common/interfaces'; import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { COMMA, ENTER } from '@angular/cdk/keycodes';
@ -39,7 +39,7 @@ import { translate } from '../i18n';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
GfSymbolModule, GfSymbolPipe,
IonIcon, IonIcon,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,

471
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -0,0 +1,471 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NotificationService } from 'apps/client/src/app/core/notification/notification.service';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
import { GfEntityLogoComponent } from '../entity-logo';
import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component';
import { GfValueComponent } from '../value';
import { GfActivitiesTableComponent } from './activities-table.component';
const activities: Activity[] = [
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2025-04-09T13:47:33.133Z'),
currency: 'USD',
date: new Date('2025-04-09T13:45:45.504Z'),
fee: 1,
id: 'a76968ff-80a4-4453-81ed-c3627dea3919',
isDraft: false,
quantity: 115,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 103.543,
updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 1,
unitPriceInAssetProfileCurrency: 103.543,
value: 11907.445,
valueInBaseCurrency: 11907.445
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-08-07T13:40:39.103Z'),
currency: 'USD',
date: new Date('2024-08-07T13:38:06.289Z'),
fee: 2.97,
id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec',
isDraft: false,
quantity: 105,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 110.24,
updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 2.97,
feeInBaseCurrency: 2.97,
unitPriceInAssetProfileCurrency: 110.24,
value: 11575.2,
valueInBaseCurrency: 11575.2
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
date: new Date('2024-03-12T15:14:38.597Z'),
fee: 45.29,
id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2',
isDraft: false,
quantity: 167,
symbolProfileId: '888d4123-db9a-42f3-9775-01b1ae6f9092',
type: 'BUY',
unitPrice: 41.0596,
updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
comment: null,
countries: [],
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
cusip: '463918102',
dataSource: 'YAHOO',
figi: 'BBG01KYQ6PV3',
figiComposite: 'BBG01KYQ6PV3',
figiShareClass: 'BBG01KYQ6QS5',
holdings: [],
id: '888d4123-db9a-42f3-9775-01b1ae6f9092',
isActive: true,
isin: 'CA4639181029',
name: 'iShares Bitcoin Trust',
updatedAt: new Date('2025-09-29T03:14:07.742Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'IBIT',
symbolMapping: {},
url: 'https://www.ishares.com',
userId: null,
activitiesCount: 6,
dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 45.29,
feeInBaseCurrency: 45.29,
unitPriceInAssetProfileCurrency: 41.0596,
value: 6856.9532,
valueInBaseCurrency: 6856.9532
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-02-23T15:53:46.907Z'),
currency: 'USD',
date: new Date('2024-02-23T15:53:15.745Z'),
fee: 3,
id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a',
isDraft: false,
quantity: 81,
symbolProfileId: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
type: 'BUY',
unitPrice: 67.995,
updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
comment: 'No data',
countries: [],
createdAt: new Date('2022-04-13T20:05:47.301Z'),
currency: 'USD',
cusip: '92206C565',
dataSource: 'YAHOO',
figi: 'BBG00LWSF7T3',
figiComposite: 'BBG00LWSF7T3',
figiShareClass: 'BBG00LWSF8K0',
holdings: [],
id: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
isActive: true,
isin: 'US92206C5655',
name: 'Vanguard Total World Bond ETF',
updatedAt: new Date('2025-10-02T06:02:56.314Z'),
sectors: [],
symbol: 'BNDW',
symbolMapping: {},
url: 'https://vanguard.com',
userId: null,
activitiesCount: 38,
dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z')
},
tags: [],
feeInAssetProfileCurrency: 3,
feeInBaseCurrency: 3,
unitPriceInAssetProfileCurrency: 67.995,
value: 5507.595,
valueInBaseCurrency: 5507.595
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2023-01-11T14:35:22.325Z'),
currency: 'USD',
date: new Date('2023-01-11T14:34:55.174Z'),
fee: 7.38,
id: '3fe87b3f-78de-407a-bc02-4189b221051f',
isDraft: false,
quantity: 55,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 89.48,
updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 7.38,
feeInBaseCurrency: 7.38,
unitPriceInAssetProfileCurrency: 89.48,
value: 4921.4,
valueInBaseCurrency: 4921.4
}
];
const dataSource = new MatTableDataSource<Activity>(activities);
export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
moduleMetadata({
imports: [
CommonModule,
GfActivityTypeComponent,
GfEntityLogoComponent,
GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
RouterModule.forChild([])
],
providers: [NotificationService]
})
]
} as Meta<GfActivitiesTableComponent>;
type Story = StoryObj<GfActivitiesTableComponent>;
export const Loading: Story = {
args: {
baseCurrency: 'USD',
dataSource: undefined,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 0
}
};
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};
export const Pagination: Story = {
args: {
baseCurrency: 'USD',
dataSource: new MatTableDataSource<Activity>(
Array.from({ length: 50 }).map((_, i) => ({
...(activities[i % activities.length] as Activity),
date: new Date(2025, 5, (i % 28) + 1),
id: `${i}`
}))
),
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 50
}
};

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

@ -1,7 +1,7 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
@ -73,7 +73,7 @@ import { GfValueComponent } from '../value/value.component';
GfActivityTypeComponent, GfActivityTypeComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfNoTransactionsInfoComponent, GfNoTransactionsInfoComponent,
GfSymbolModule, GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

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

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { FocusableOption } from '@angular/cdk/a11y'; import { FocusableOption } from '@angular/cdk/a11y';
@ -24,7 +24,7 @@ import {
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfSymbolModule, RouterModule], imports: [GfSymbolPipe, RouterModule],
selector: 'gf-assistant-list-item', selector: 'gf-assistant-list-item',
styleUrls: ['./assistant-list-item.scss'], styleUrls: ['./assistant-list-item.scss'],
templateUrl: './assistant-list-item.html' templateUrl: './assistant-list-item.html'

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

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
@ -76,7 +76,7 @@ import {
FormsModule, FormsModule,
GfAssistantListItemComponent, GfAssistantListItemComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfSymbolModule, GfSymbolPipe,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@ -169,6 +169,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
public tags: Filter[] = []; public tags: Filter[] = [];
private readonly PRESELECTION_DELAY = 100;
private filterTypes: Filter['type'][] = [ private filterTypes: Filter['type'][] = [
'ACCOUNT', 'ACCOUNT',
'ASSET_CLASS', 'ASSET_CLASS',
@ -176,7 +178,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
'SYMBOL', 'SYMBOL',
'TAG' 'TAG'
]; ];
private keyManager: FocusKeyManager<GfAssistantListItemComponent>; private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private preselectionTimeout: ReturnType<typeof setTimeout>;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -344,6 +348,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.subscribe({ .subscribe({
next: (searchResults) => { next: (searchResults) => {
this.searchResults = searchResults; this.searchResults = searchResults;
this.preselectFirstItem();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}, },
error: (error) => { error: (error) => {
@ -585,6 +592,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public ngOnDestroy() { public ngOnDestroy() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
@ -595,6 +606,58 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
private getFirstSearchResultItem() {
if (this.searchResults.quickLinks?.length > 0) {
return this.searchResults.quickLinks[0];
}
if (this.searchResults.accounts?.length > 0) {
return this.searchResults.accounts[0];
}
if (this.searchResults.holdings?.length > 0) {
return this.searchResults.holdings[0];
}
if (this.searchResults.assetProfiles?.length > 0) {
return this.searchResults.assetProfiles[0];
}
return null;
}
private preselectFirstItem() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.preselectionTimeout = setTimeout(() => {
if (!this.isOpen || !this.searchFormControl.value) {
return;
}
const firstItem = this.getFirstSearchResultItem();
if (!firstItem) {
return;
}
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.setFirstItemActive();
const currentFocusedItem = this.getCurrentAssistantListItem();
if (currentFocusedItem) {
currentFocusedItem.focus();
}
this.changeDetectorRef.markForCheck();
}, this.PRESELECTION_DELAY);
}
private searchAccounts(aSearchTerm: string): Observable<ISearchResultItem[]> { private searchAccounts(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService return this.dataService
.fetchAccounts({ .fetchAccounts({

2
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -1,4 +1,3 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -34,7 +33,6 @@ import { GfValueComponent } from '../value/value.component';
imports: [ imports: [
CommonModule, CommonModule,
GfEntityLogoComponent, GfEntityLogoComponent,
GfSymbolModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

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

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { LookupItem } from '@ghostfolio/common/interfaces'; import { LookupItem } from '@ghostfolio/common/interfaces';
@ -57,7 +57,7 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
imports: [ imports: [
FormsModule, FormsModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolModule, GfSymbolPipe,
MatAutocompleteModule, MatAutocompleteModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,

4
libs/ui/src/lib/top-holdings/top-holdings.component.ts

@ -1,4 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -46,7 +46,7 @@ import { GfValueComponent } from '../value/value.component';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
GfSymbolModule, GfSymbolPipe,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatPaginatorModule, MatPaginatorModule,

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.205.0", "version": "2.206.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.205.0", "version": "2.206.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.205.0", "version": "2.206.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",

Loading…
Cancel
Save