diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb4bb494..97f35cb54 100644 --- a/CHANGELOG.md +++ b/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/), 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 ### Unreleased diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index bc2d22e51..cb1e2d4af 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -14,6 +14,7 @@ import { Inject, Param, Post, + Put, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccessService } from './access.service'; import { CreateAccessDto } from './create-access.dto'; +import { UpdateAccessDto } from './update-access.dto'; @Controller('access') export class AccessController { @@ -39,7 +41,7 @@ export class AccessController { include: { granteeUser: true }, - orderBy: { granteeUserId: 'asc' }, + orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], where: { userId: this.request.user.id } }); @@ -103,9 +105,12 @@ export class AccessController { @HasPermission(permissions.deleteAccess) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteAccess(@Param('id') id: string): Promise { - 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( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -116,4 +121,52 @@ export class AccessController { id }); } + + @HasPermission(permissions.updateAccess) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAccess( + @Body() data: UpdateAccessDto, + @Param('id') id: string + ): Promise { + 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 + ); + } + } } diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 8403cdc09..70e46dc36 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -20,14 +20,14 @@ export class AccessService { } public async accesses(params: { + cursor?: Prisma.AccessWhereUniqueInput; include?: Prisma.AccessInclude; + orderBy?: Prisma.Enumerable; skip?: number; take?: number; - cursor?: Prisma.AccessWhereUniqueInput; where?: Prisma.AccessWhereInput; - orderBy?: Prisma.AccessOrderByWithRelationInput; }): Promise { - const { include, skip, take, cursor, where, orderBy } = params; + const { cursor, include, orderBy, skip, take, where } = params; return this.prismaService.access.findMany({ cursor, @@ -52,4 +52,17 @@ export class AccessService { where }); } + + public async updateAccess({ + data, + where + }: { + data: Prisma.AccessUpdateInput; + where: Prisma.AccessWhereUniqueInput; + }): Promise { + return this.prismaService.access.update({ + data, + where + }); + } } diff --git a/apps/api/src/app/access/update-access.dto.ts b/apps/api/src/app/access/update-access.dto.ts new file mode 100644 index 000000000..2850186f9 --- /dev/null +++ b/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[]; +} diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 27cc088d1..66f8483b4 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/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 { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, @@ -22,6 +23,7 @@ import { } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { + DateRange, MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; @@ -161,9 +163,21 @@ export class AdminController { @HasPermission(permissions.accessAdminControl) public async gatherSymbol( @Param('dataSource') dataSource: DataSource, - @Param('symbol') symbol: string + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange ): Promise { - 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; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index d703cf604..5659818a8 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -195,7 +195,6 @@ export class PortfolioController { 'excludedAccountsAndActivities', 'fees', 'filteredValueInBaseCurrency', - 'fireWealth', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', 'interest', diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index f5b4ab1c6..e73f79784 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2092,9 +2092,13 @@ export class PortfolioService { filteredValueInPercentage: netWorth ? filteredValueInBaseCurrency.div(netWorth).toNumber() : undefined, - fireWealth: new Big(currentValueInBaseCurrency) - .minus(emergencyFundHoldingsValueInBaseCurrency) - .toNumber(), + fireWealth: { + today: { + valueInBaseCurrency: new Big(currentValueInBaseCurrency) + .minus(emergencyFundHoldingsValueInBaseCurrency) + .toNumber() + } + }, grossPerformance: new Big(netPerformance).plus(fees).toNumber(), grossPerformanceWithCurrencyEffect: new Big( netPerformanceWithCurrencyEffect diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index e64416258..8bb8f8327 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/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()); - if (etfInformation.website) { + if (etfInformation?.website) { response.url = etfInformation.website; } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 31edf6ffc..dd93e3e47 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/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 }); - const dataGatheringItems = (await this.getSymbolsMax()).filter( - (dataGatheringItem) => { + const dataGatheringItems = (await this.getSymbolsMax()) + .filter((dataGatheringItem) => { return ( dataGatheringItem.dataSource === dataSource && dataGatheringItem.symbol === symbol ); - } - ); + }) + .map((item) => ({ + ...item, + date: date ?? item.date + })); + await this.gatherSymbols({ dataGatheringItems, priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 53db1f06b..0e5a2dead 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -42,7 +42,7 @@ const routes: Routes = [ { path: internalRoutes.auth.path, 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 }, { @@ -94,7 +94,7 @@ const routes: Routes = [ { path: publicRoutes.openStartup.path, 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, diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index be374db9c..e61ee0c9e 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -65,6 +65,14 @@ + @if (user?.settings?.isExperimentalFeatures) { + + } @if (element.type === 'PUBLIC') { + } + @if ( + user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC' + ) {
} + diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 178df374d..bdb9af6ed 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/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) => { if (params['createDialog']) { 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() { this.unsubscribeSubject.next(); 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() { this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { return { diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index 2979fd6fa..8160c2c8e 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -64,6 +64,7 @@ [showActions]="hasPermissionToDeleteAccess" [user]="user" (accessDeleted)="onDeleteAccess($event)" + (accessToUpdate)="onUpdateAccess($event)" /> @if (hasPermissionToCreateAccess) {
diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index 93f444572..a7e628bc9 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,10 +1,9 @@ import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; @Directive({ - selector: '[gfFileDrop]', - standalone: false + selector: '[gfFileDrop]' }) -export class FileDropDirective { +export class GfFileDropDirective { @Output() filesDropped = new EventEmitter(); @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { diff --git a/apps/client/src/app/directives/file-drop/file-drop.module.ts b/apps/client/src/app/directives/file-drop/file-drop.module.ts deleted file mode 100644 index a0148516e..000000000 --- a/apps/client/src/app/directives/file-drop/file-drop.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { FileDropDirective } from './file-drop.directive'; - -@NgModule({ - declarations: [FileDropDirective], - exports: [FileDropDirective] -}) -export class GfFileDropModule {} diff --git a/apps/client/src/app/pages/auth/auth-page-routing.module.ts b/apps/client/src/app/pages/auth/auth-page-routing.module.ts deleted file mode 100644 index 2c9741845..000000000 --- a/apps/client/src/app/pages/auth/auth-page-routing.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/client/src/app/pages/auth/auth-page.component.ts b/apps/client/src/app/pages/auth/auth-page.component.ts index 4061dd227..082401d6d 100644 --- a/apps/client/src/app/pages/auth/auth-page.component.ts +++ b/apps/client/src/app/pages/auth/auth-page.component.ts @@ -11,11 +11,10 @@ import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'gf-auth-page', - templateUrl: './auth-page.html', 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(); public constructor( diff --git a/apps/client/src/app/pages/auth/auth-page.module.ts b/apps/client/src/app/pages/auth/auth-page.module.ts deleted file mode 100644 index dd6a9ebe3..000000000 --- a/apps/client/src/app/pages/auth/auth-page.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/client/src/app/pages/auth/auth-page.routes.ts b/apps/client/src/app/pages/auth/auth-page.routes.ts new file mode 100644 index 000000000..0ed6151de --- /dev/null +++ b/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 + } +]; diff --git a/apps/client/src/app/pages/open/open-page-routing.module.ts b/apps/client/src/app/pages/open/open-page-routing.module.ts deleted file mode 100644 index c56fe80ba..000000000 --- a/apps/client/src/app/pages/open/open-page-routing.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/client/src/app/pages/open/open-page.component.ts b/apps/client/src/app/pages/open/open-page.component.ts index c876c3cc8..6521951da 100644 --- a/apps/client/src/app/pages/open/open-page.component.ts +++ b/apps/client/src/app/pages/open/open-page.component.ts @@ -1,18 +1,28 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { Statistics, User } from '@ghostfolio/common/interfaces'; - -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { GfValueComponent } from '@ghostfolio/ui/value'; + +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'; @Component({ host: { class: 'page' }, + imports: [CommonModule, GfValueComponent, MatCardModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-open-page', styleUrls: ['./open-page.scss'], - templateUrl: './open-page.html', - standalone: false + templateUrl: './open-page.html' }) -export class OpenPageComponent implements OnDestroy, OnInit { +export class GfOpenPageComponent implements OnDestroy, OnInit { public statistics: Statistics; public user: User; diff --git a/apps/client/src/app/pages/open/open-page.module.ts b/apps/client/src/app/pages/open/open-page.module.ts deleted file mode 100644 index ab48f2d4a..000000000 --- a/apps/client/src/app/pages/open/open-page.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/client/src/app/pages/open/open-page.routes.ts b/apps/client/src/app/pages/open/open-page.routes.ts new file mode 100644 index 000000000..da04d6f5b --- /dev/null +++ b/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 + } +]; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 228eac5c3..2439a4b65 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/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 { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component'; import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component'; -import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module'; -import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; +import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; +import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe'; import { DataService } from '@ghostfolio/client/services/data.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { PortfolioPosition } from '@ghostfolio/common/interfaces'; @@ -15,6 +15,7 @@ import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -59,11 +60,12 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'd-flex flex-column h-100' }, imports: [ + CommonModule, GfActivitiesTableComponent, GfDialogFooterComponent, GfDialogHeaderComponent, - GfFileDropModule, - GfSymbolModule, + GfFileDropDirective, + GfSymbolPipe, IonIcon, MatButtonModule, MatDialogModule, diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index ab0fbc787..c80b55c45 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -1,7 +1,7 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { User } from '@ghostfolio/common/interfaces'; +import { FireWealth, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators'; }) export class GfFirePageComponent implements OnDestroy, OnInit { public deviceType: string; - public fireWealth: Big; + public fireWealth: FireWealth; public hasImpersonationId: boolean; public hasPermissionToUpdateUserSettings: boolean; public isLoading = false; @@ -55,17 +55,24 @@ export class GfFirePageComponent implements OnDestroy, OnInit { .fetchPortfolioDetails() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ summary }) => { - this.fireWealth = summary.fireWealth - ? new Big(summary.fireWealth) - : new Big(0); - + this.fireWealth = { + today: { + valueInBaseCurrency: summary.fireWealth + ? summary.fireWealth.today.valueInBaseCurrency + : 0 + } + }; if (this.user.subscription?.type === 'Basic') { - this.fireWealth = new Big(10000); + this.fireWealth = { + today: { + valueInBaseCurrency: 10000 + } + }; } - this.withdrawalRatePerYear = this.fireWealth.mul( - this.user.settings.safeWithdrawalRate - ); + this.withdrawalRatePerYear = Big( + this.fireWealth.today.valueInBaseCurrency + ).mul(this.user.settings.safeWithdrawalRate); this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12); diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index df81991c3..d6548f761 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -14,7 +14,7 @@ [colorScheme]="user?.settings?.colorScheme" [currency]="user?.settings?.baseCurrency" [deviceType]="deviceType" - [fireWealth]="fireWealth?.toNumber()" + [fireWealth]="fireWealth?.today.valueInBaseCurrency" [hasPermissionToUpdateUserSettings]=" !hasImpersonationId && hasPermissionToUpdateUserSettings " @@ -100,7 +100,7 @@ [isCurrency]="true" [locale]="user?.settings?.locale" [unit]="user?.settings?.baseCurrency" - [value]="fireWealth?.toNumber()" + [value]="fireWealth?.today.valueInBaseCurrency" />   diff --git a/apps/client/src/app/pipes/symbol/symbol.module.ts b/apps/client/src/app/pipes/symbol/symbol.module.ts deleted file mode 100644 index 7ba1c1c73..000000000 --- a/apps/client/src/app/pipes/symbol/symbol.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { SymbolPipe } from './symbol.pipe'; - -@NgModule({ - declarations: [SymbolPipe], - exports: [SymbolPipe] -}) -export class GfSymbolModule {} diff --git a/apps/client/src/app/pipes/symbol/symbol.pipe.ts b/apps/client/src/app/pipes/symbol/symbol.pipe.ts index 2b30d1041..6f4981699 100644 --- a/apps/client/src/app/pipes/symbol/symbol.pipe.ts +++ b/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'; @Pipe({ - name: 'gfSymbol', - standalone: false + name: 'gfSymbol' }) -export class SymbolPipe implements PipeTransform { +export class GfSymbolPipe implements PipeTransform { public transform(aSymbol: string) { return prettifySymbol(aSymbol); } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 6ab370399..c2678924b 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -1,4 +1,5 @@ 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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.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); } + public putAccess(aAccess: UpdateAccessDto) { + return this.http.put(`/api/v1/access/${aAccess.id}`, aAccess); + } + public putAccount(aAccount: UpdateAccountDto) { return this.http.put(`/api/v1/account/${aAccount.id}`, aAccount); } diff --git a/libs/common/src/lib/interfaces/fire-wealth.interface.ts b/libs/common/src/lib/interfaces/fire-wealth.interface.ts new file mode 100644 index 000000000..42fbeabd4 --- /dev/null +++ b/libs/common/src/lib/interfaces/fire-wealth.interface.ts @@ -0,0 +1,3 @@ +export interface FireWealth { + today: { valueInBaseCurrency: number }; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 6529fa3ef..1da2236e8 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/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 { FilterGroup } from './filter-group.interface'; import type { Filter } from './filter.interface'; +import type { FireWealth } from './fire-wealth.interface'; import type { HistoricalDataItem } from './historical-data-item.interface'; import type { HoldingWithParents } from './holding-with-parents.interface'; import type { Holding } from './holding.interface'; @@ -104,6 +105,7 @@ export { Export, Filter, FilterGroup, + FireWealth, HistoricalDataItem, HistoricalResponse, Holding, diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 419915a79..05fac0ba0 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/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'; export interface PortfolioSummary extends PortfolioPerformance { @@ -16,7 +17,7 @@ export interface PortfolioSummary extends PortfolioPerformance { fees: number; filteredValueInBaseCurrency?: number; filteredValueInPercentage?: number; - fireWealth: number; + fireWealth: FireWealth; grossPerformance: number; grossPerformanceWithCurrencyEffect: number; interest: number; diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 52794f7dc..51f327d32 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -49,6 +49,7 @@ export const permissions = { syncDemoUserAccount: 'syncDemoUserAccount', toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', + updateAccess: 'updateAccess', updateAuthDevice: 'updateAuthDevice', updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', @@ -93,6 +94,7 @@ export function getPermissions(aRole: Role): string[] { permissions.readTags, permissions.readWatchlist, permissions.updateAccount, + permissions.updateAccess, permissions.updateAuthDevice, permissions.updateMarketData, permissions.updateMarketDataOfOwnAssetProfile, @@ -133,6 +135,7 @@ export function getPermissions(aRole: Role): string[] { permissions.readMarketDataOfOwnAssetProfile, permissions.readWatchlist, permissions.updateAccount, + permissions.updateAccess, permissions.updateAuthDevice, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.ts b/libs/ui/src/lib/activities-filter/activities-filter.component.ts index c31a5fecd..cb659988a 100644 --- a/libs/ui/src/lib/activities-filter/activities-filter.component.ts +++ b/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 { COMMA, ENTER } from '@angular/cdk/keycodes'; @@ -39,7 +39,7 @@ import { translate } from '../i18n'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, - GfSymbolModule, + GfSymbolPipe, IonIcon, MatAutocompleteModule, MatButtonModule, diff --git a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts new file mode 100644 index 000000000..5e774730b --- /dev/null +++ b/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(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; + +type Story = StoryObj; + +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( + 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 + } +}; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 4be9fbec6..ce2de1caa 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/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 { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; 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 { DEFAULT_PAGE_SIZE, TAG_ID_EXCLUDE_FROM_ANALYSIS @@ -73,7 +73,7 @@ import { GfValueComponent } from '../value/value.component'; GfActivityTypeComponent, GfEntityLogoComponent, GfNoTransactionsInfoComponent, - GfSymbolModule, + GfSymbolPipe, GfValueComponent, IonIcon, MatButtonModule, diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts index 1cfcfec6a..f75aaea01 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts +++ b/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 { FocusableOption } from '@angular/cdk/a11y'; @@ -24,7 +24,7 @@ import { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [GfSymbolModule, RouterModule], + imports: [GfSymbolPipe, RouterModule], selector: 'gf-assistant-list-item', styleUrls: ['./assistant-list-item.scss'], templateUrl: './assistant-list-item.html' diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index e5d0dd6da..3fc1cc232 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/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 { DataService } from '@ghostfolio/client/services/data.service'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; @@ -76,7 +76,7 @@ import { FormsModule, GfAssistantListItemComponent, GfEntityLogoComponent, - GfSymbolModule, + GfSymbolPipe, IonIcon, MatButtonModule, MatFormFieldModule, @@ -169,6 +169,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }; public tags: Filter[] = []; + private readonly PRESELECTION_DELAY = 100; + private filterTypes: Filter['type'][] = [ 'ACCOUNT', 'ASSET_CLASS', @@ -176,7 +178,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { 'SYMBOL', 'TAG' ]; + private keyManager: FocusKeyManager; + private preselectionTimeout: ReturnType; private unsubscribeSubject = new Subject(); public constructor( @@ -344,6 +348,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { .subscribe({ next: (searchResults) => { this.searchResults = searchResults; + + this.preselectFirstItem(); + this.changeDetectorRef.markForCheck(); }, error: (error) => { @@ -585,6 +592,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { } public ngOnDestroy() { + if (this.preselectionTimeout) { + clearTimeout(this.preselectionTimeout); + } + this.unsubscribeSubject.next(); 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 { return this.dataService .fetchAccounts({ diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index 89bb4a541..1c46e18db 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/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 { AssetProfileIdentifier, @@ -34,7 +33,6 @@ import { GfValueComponent } from '../value/value.component'; imports: [ CommonModule, GfEntityLogoComponent, - GfSymbolModule, GfValueComponent, MatButtonModule, MatDialogModule, diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts index f6c289b72..80315fc06 100644 --- a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts +++ b/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 { LookupItem } from '@ghostfolio/common/interfaces'; @@ -57,7 +57,7 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; imports: [ FormsModule, GfPremiumIndicatorComponent, - GfSymbolModule, + GfSymbolPipe, MatAutocompleteModule, MatFormFieldModule, MatInputModule, diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index b4ebf4c8c..c9f7e0372 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/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 { AssetProfileIdentifier, @@ -46,7 +46,7 @@ import { GfValueComponent } from '../value/value.component'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, - GfSymbolModule, + GfSymbolPipe, GfValueComponent, MatButtonModule, MatPaginatorModule, diff --git a/package-lock.json b/package-lock.json index 8f8676556..2ed25d7c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "2.205.0", + "version": "2.206.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "2.205.0", + "version": "2.206.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 28881f546..8717f58df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.205.0", + "version": "2.206.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",