diff --git a/CHANGELOG.md b/CHANGELOG.md index 407767cac..db2fda87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the holding detail dialog by adding a copy-to-clipboard button for the ISIN number (experimental) - Extended the holding detail dialog by adding a copy-to-clipboard button for the symbol (experimental) - Extended the user detail dialog of the admin control panel’s users section by adding a copy-to-clipboard button for the user id +- Added a filter by activity type to the activities page (experimental) ### Changed diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 141fd4c82..8b42514f8 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -13,6 +13,7 @@ import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; +import { splitStringToArray } from '@ghostfolio/common/helper'; import { ActivitiesResponse, ActivityResponse @@ -37,7 +38,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Order, Prisma } from '@prisma/client'; +import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -112,6 +113,7 @@ export class ActivitiesController { public async getAllActivities( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, + @Query('activityTypes') filterByTypes?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('range') dateRange?: DateRange, @@ -122,6 +124,10 @@ export class ActivitiesController { @Query('tags') filterByTags?: string, @Query('take') take?: number ): Promise { + const types = filterByTypes + ? (splitStringToArray(filterByTypes) as ActivityType[]) + : undefined; + let endDate: Date; let startDate: Date; @@ -147,6 +153,7 @@ export class ActivitiesController { sortColumn, sortDirection, startDate, + types, userCurrency, includeDrafts: true, skip: isNaN(skip) ? undefined : skip, diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts index 6fda8f17f..6d31b6700 100644 --- a/apps/api/src/app/export/export.controller.ts +++ b/apps/api/src/app/export/export.controller.ts @@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { splitStringToArray } from '@ghostfolio/common/helper'; import { ExportResponse } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -15,6 +16,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { Type as ActivityType } from '@prisma/client'; import { ExportService } from './export.service'; @@ -33,12 +35,16 @@ export class ExportController { public async export( @Query('accounts') filterByAccounts?: string, @Query('activityIds') filterByActivityIds?: string, + @Query('activityTypes') filterByTypes?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { - const activityIds = filterByActivityIds?.split(',') ?? []; + const activityIds = splitStringToArray(filterByActivityIds); + const activityTypes = filterByTypes + ? (splitStringToArray(filterByTypes) as ActivityType[]) + : undefined; const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -49,6 +55,7 @@ export class ExportController { return this.exportService.export({ activityIds, + activityTypes, filters, userId: this.request.user.id, userSettings: this.request.user.settings.settings diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 4f2fb3309..267adcd37 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -10,7 +10,7 @@ import { } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; -import { Platform, Prisma } from '@prisma/client'; +import { Platform, Prisma, Type as ActivityType } from '@prisma/client'; import { groupBy, uniqBy } from 'lodash'; @Injectable() @@ -24,11 +24,13 @@ export class ExportService { public async export({ activityIds, + activityTypes, filters, userId, userSettings }: { activityIds?: string[]; + activityTypes?: ActivityType[]; filters?: Filter[]; userId: string; userSettings: UserSettings; @@ -41,6 +43,7 @@ export class ExportService { let { activities } = await this.activitiesService.getActivities({ filters, userId, + types: activityTypes, includeDrafts: true, sortColumn: 'date', sortDirection: 'asc', diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 052119246..db0e71c04 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -1,3 +1,4 @@ +import { splitStringToArray } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -23,14 +24,14 @@ export class ApiService { filterBySymbol?: string; filterByTags?: string; }): Filter[] { - const accountIds = filterByAccounts?.split(',') ?? []; - const assetClasses = filterByAssetClasses?.split(',') ?? []; - const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; + const accountIds = splitStringToArray(filterByAccounts); + const assetClasses = splitStringToArray(filterByAssetClasses); + const assetSubClasses = splitStringToArray(filterByAssetSubClasses); const dataSource = filterByDataSource; const holdingType = filterByHoldingType; const searchQuery = filterBySearchQuery?.toLowerCase(); const symbol = filterBySymbol; - const tagIds = filterByTags?.split(',') ?? []; + const tagIds = splitStringToArray(filterByTags); const filters = [ ...accountIds.map((accountId) => { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index b9dc9077c..4e8c98955 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -54,6 +54,7 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa templateUrl: './activities-page.html' }) export class GfActivitiesPageComponent implements OnInit { + public activityTypesFilter: string[] = []; public dataSource: MatTableDataSource; public deviceType: string; public hasImpersonationId: boolean; @@ -140,8 +141,11 @@ export class GfActivitiesPageComponent implements OnInit { this.dataService .fetchActivities({ - range, + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined, filters: this.userService.getFilters(), + range, skip: this.pageIndex * this.pageSize, sortColumn: this.sortColumn, sortDirection: this.sortDirection, @@ -217,7 +221,12 @@ export class GfActivitiesPageComponent implements OnInit { let fetchExportParams: any = { activityIds }; if (!activityIds) { - fetchExportParams = { filters: this.userService.getFilters() }; + fetchExportParams = { + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined, + filters: this.userService.getFilters() + }; } this.dataService @@ -318,6 +327,13 @@ export class GfActivitiesPageComponent implements OnInit { this.fetchActivities(); } + public onTypesFilterChanged(types: string[]) { + this.activityTypesFilter = types; + this.pageIndex = 0; + + this.fetchActivities(); + } + public onUpdateActivity(aActivity: Activity) { this.router.navigate([], { queryParams: { activityId: aActivity.id, editDialog: true } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 80ad71b79..2a72dcfd2 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -10,6 +10,7 @@ [hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToExportActivities]="!hasImpersonationId" + [hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures" [locale]="user?.settings?.locale" [pageIndex]="pageIndex" [pageSize]="pageSize" @@ -32,6 +33,7 @@ (importDividends)="onImportDividends()" (pageChanged)="onChangePage($event)" (sortChanged)="onSortChanged($event)" + (typesFilterChanged)="onTypesFilterChanged($event)" /> diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index a339c6dab..e794799a8 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -1,6 +1,7 @@ import { extractNumberFromString, - getNumberFormatGroup + getNumberFormatGroup, + splitStringToArray } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -116,4 +117,22 @@ describe('Helper', () => { expect(getNumberFormatGroup()).toEqual(','); }); }); + + describe('splitStringToArray', () => { + it('should split a comma-separated string', () => { + expect(splitStringToArray('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('should return a single-element array for a string without commas', () => { + expect(splitStringToArray('a')).toEqual(['a']); + }); + + it('should return an empty array for undefined', () => { + expect(splitStringToArray(undefined)).toEqual([]); + }); + + it('should return an empty array for no argument', () => { + expect(splitStringToArray()).toEqual([]); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 4db1fcf2d..6e5e8e6a4 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -474,3 +474,7 @@ export function resolveMarketCondition( return { emoji: undefined }; } } + +export function splitStringToArray(aString?: string): string[] { + return aString?.split(',') ?? []; +} diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index bdb1e6373..18a78cef4 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -1,77 +1,101 @@ -@if (hasPermissionToCreateActivity) { -
- - @if (hasPermissionToExportActivities) { - +
+
+ @if (hasPermissionToFilterByType) { + + Type + + @for ( + activityType of activityTypes | keyvalue; + track activityType.key + ) { + + {{ activityType.value }} + + } + + } - +
+ + @if (hasPermissionToCreateActivity) { +
@if (hasPermissionToExportActivities) { + } + + - } - @if (hasPermissionToExportActivities) { + @if (hasPermissionToExportActivities) { + + } + @if (hasPermissionToExportActivities) { + + } +
- } -
- -
-
-} + +
+ } +
(); @Output() selectedActivities = new EventEmitter(); @Output() sortChanged = new EventEmitter(); + @Output() typesFilterChanged = new EventEmitter(); @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; + public readonly activityTypes = new Map(); public hasDrafts = false; public hasErrors = false; public isUUID = isUUID; public selectedRows = new SelectionModel(true, []); + public typesFilter = new FormControl([]); public readonly dataSource = input.required< MatTableDataSource | undefined @@ -189,6 +201,10 @@ export class GfActivitiesTableComponent private readonly unsubscribeSubject = new Subject(); public constructor() { + for (const type of Object.keys(ActivityType) as ActivityType[]) { + this.activityTypes.set(ActivityType[type], translate(ActivityType[type])); + } + addIcons({ alertCircleOutline, calendarClearOutline, @@ -214,6 +230,12 @@ export class GfActivitiesTableComponent this.selectedActivities.emit(selectedRows.source.selected); }); } + + this.typesFilter.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((types) => { + this.typesFilterChanged.emit(types ?? []); + }); } public ngAfterViewInit() { diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index f3e3fb81f..7f2dac0b1 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -209,6 +209,7 @@ export class DataService { } public fetchActivities({ + activityTypes, filters, range, skip, @@ -216,6 +217,7 @@ export class DataService { sortDirection, take }: { + activityTypes?: string[]; filters?: Filter[]; range?: DateRange; skip?: number; @@ -225,6 +227,10 @@ export class DataService { }): Observable { let params = this.buildFiltersAsQueryParams({ filters }); + if (activityTypes?.length) { + params = params.append('activityTypes', activityTypes.join(',')); + } + if (range) { params = params.append('range', range); } @@ -411,9 +417,11 @@ export class DataService { public fetchExport({ activityIds, + activityTypes, filters }: { activityIds?: string[]; + activityTypes?: string[]; filters?: Filter[]; } = {}) { let params = this.buildFiltersAsQueryParams({ filters }); @@ -422,6 +430,10 @@ export class DataService { params = params.append('activityIds', activityIds.join(',')); } + if (activityTypes?.length) { + params = params.append('activityTypes', activityTypes.join(',')); + } + return this.http.get('/api/v1/export', { params }); diff --git a/prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql b/prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql new file mode 100644 index 000000000..ba4d1b1ff --- /dev/null +++ b/prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Order_type_idx" ON "Order"("type"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 232dde9ca..50aac91fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -158,6 +158,7 @@ model Order { @@index([accountId]) @@index([date]) @@index([isDraft]) + @@index([type]) @@index([userId]) }