diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 141fd4c82..01e947905 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'; @@ -120,8 +121,13 @@ export class ActivitiesController { @Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, - @Query('take') take?: number + @Query('take') take?: number, + @Query('activityTypes') filterByTypes?: string ): 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..89fde83b3 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'; @@ -36,9 +38,13 @@ export class ExportController { @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query('tags') filterByTags?: string, + @Query('activityTypes') filterByTypes?: string ): Promise { const activityIds = filterByActivityIds?.split(',') ?? []; + const activityTypes = filterByTypes + ? (splitStringToArray(filterByTypes) as ActivityType[]) + : undefined; const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -50,6 +56,7 @@ export class ExportController { return this.exportService.export({ activityIds, filters, + activityTypes: activityTypes, 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..ee469d4b6 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,12 +24,14 @@ export class ExportService { public async export({ activityIds, + activityTypes, filters, userId, userSettings }: { activityIds?: string[]; filters?: Filter[]; + activityTypes?: ActivityType[]; userId: string; userSettings: UserSettings; }): Promise { @@ -40,6 +42,7 @@ export class ExportService { let { activities } = await this.activitiesService.getActivities({ filters, + types: activityTypes, userId, includeDrafts: true, sortColumn: 'date', 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..74eb33f39 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; @@ -145,7 +146,10 @@ export class GfActivitiesPageComponent implements OnInit { skip: this.pageIndex * this.pageSize, sortColumn: this.sortColumn, sortDirection: this.sortDirection, - take: this.pageSize + take: this.pageSize, + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ activities, count }) => { @@ -217,7 +221,12 @@ export class GfActivitiesPageComponent implements OnInit { let fetchExportParams: any = { activityIds }; if (!activityIds) { - fetchExportParams = { filters: this.userService.getFilters() }; + fetchExportParams = { + filters: this.userService.getFilters(), + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined + }; } 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..76feb0426 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -214,8 +214,10 @@ export class DataService { skip, sortColumn, sortDirection, - take + take, + activityTypes }: { + activityTypes?: string[]; filters?: Filter[]; range?: DateRange; skip?: number; @@ -245,6 +247,10 @@ export class DataService { params = params.append('take', take); } + if (activityTypes?.length) { + params = params.append('activityTypes', activityTypes.join(',')); + } + return this.http.get('/api/v1/activities', { params }).pipe( map(({ activities, count }) => { for (const activity of activities) { @@ -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]) }