Browse Source

Feature/add filter on activities types

pull/6622/head
Airthee 1 week ago
parent
commit
5ae2a40a78
No known key found for this signature in database GPG Key ID: C7EADC5599E355EC
  1. 11
      apps/api/src/app/activities/activities.controller.ts
  2. 9
      apps/api/src/app/export/export.controller.ts
  3. 5
      apps/api/src/app/export/export.service.ts
  4. 9
      apps/api/src/services/api/api.service.ts
  5. 20
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  6. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  7. 21
      libs/common/src/lib/helper.spec.ts
  8. 4
      libs/common/src/lib/helper.ts
  9. 18
      libs/ui/src/lib/activities-table/activities-table.component.html
  10. 24
      libs/ui/src/lib/activities-table/activities-table.component.ts
  11. 16
      libs/ui/src/lib/services/data.service.ts
  12. 2
      prisma/migrations/20260321200654_add_index_on_order_type/migration.sql
  13. 1
      prisma/schema.prisma

11
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('types') filterByTypes?: string
): Promise<ActivitiesResponse> {
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,

9
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,7 +38,8 @@ export class ExportController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query('tags') filterByTags?: string,
@Query('types') filterByTypes?: string
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
@ -46,10 +49,14 @@ export class ExportController {
filterBySymbol,
filterByTags
});
const types = filterByTypes
? (splitStringToArray(filterByTypes) as ActivityType[])
: undefined;
return this.exportService.export({
activityIds,
filters,
types,
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});

5
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()
@ -25,11 +25,13 @@ export class ExportService {
public async export({
activityIds,
filters,
types,
userId,
userSettings
}: {
activityIds?: string[];
filters?: Filter[];
types?: ActivityType[];
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
@ -40,6 +42,7 @@ export class ExportService {
let { activities } = await this.activitiesService.getActivities({
filters,
types,
userId,
includeDrafts: true,
sortColumn: 'date',

9
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) => {

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

@ -54,11 +54,13 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
templateUrl: './activities-page.html'
})
export class GfActivitiesPageComponent implements OnInit {
public activityTypeFilter: string[] = [];
public dataSource: MatTableDataSource<Activity>;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateActivity: boolean;
public hasPermissionToDeleteActivity: boolean;
public hasPermissionToFilterByType = true;
public pageIndex = 0;
public pageSize = DEFAULT_PAGE_SIZE;
public routeQueryParams: Subscription;
@ -145,7 +147,10 @@ export class GfActivitiesPageComponent implements OnInit {
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
take: this.pageSize
take: this.pageSize,
types: this.activityTypeFilter.length
? this.activityTypeFilter
: undefined
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => {
@ -217,7 +222,12 @@ export class GfActivitiesPageComponent implements OnInit {
let fetchExportParams: any = { activityIds };
if (!activityIds) {
fetchExportParams = { filters: this.userService.getFilters() };
fetchExportParams = {
filters: this.userService.getFilters(),
types: this.activityTypeFilter.length
? this.activityTypeFilter
: undefined
};
}
this.dataService
@ -310,6 +320,12 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
public onTypesFilterChanged(types: string[]) {
this.activityTypeFilter = types;
this.pageIndex = 0;
this.fetchActivities();
}
public onSortChanged({ active, direction }: Sort) {
this.pageIndex = 0;
this.sortColumn = active;

2
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -10,6 +10,7 @@
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="hasPermissionToFilterByType"
[locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
@ -32,6 +33,7 @@
(importDividends)="onImportDividends()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"
(typesFilterChanged)="onTypesFilterChanged($event)"
/>
</div>
</div>

21
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([]);
});
});
});

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

@ -463,6 +463,10 @@ export function resolveFearAndGreedIndex(aValue: number) {
}
}
export function splitStringToArray(aString?: string): string[] {
return aString?.split(',') ?? [];
}
export function resolveMarketCondition(
aMarketCondition: Benchmark['marketCondition']
) {

18
libs/ui/src/lib/activities-table/activities-table.component.html

@ -1,3 +1,21 @@
@if (hasPermissionToFilterByType) {
<div class="d-none d-lg-flex">
<mat-form-field appearance="outline" class="without-hint">
<mat-label i18n>Type</mat-label>
<mat-select multiple [formControl]="typesFilter">
@for (
activityType of activityTypes | keyvalue;
track activityType.key
) {
<mat-option [value]="activityType.key">
{{ activityType.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (hasPermissionToCreateActivity) {
<div class="d-flex justify-content-end">
<button

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

@ -29,14 +29,17 @@ import {
inject,
input
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatMenuModule } from '@angular/material/menu';
import {
MatPaginator,
MatPaginatorModule,
PageEvent
} from '@angular/material/paginator';
import { MatSelectModule } from '@angular/material/select';
import {
MatSort,
MatSortModule,
@ -46,6 +49,7 @@ import {
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone';
import { Type as ActivityType } from '@prisma/client';
import { isUUID } from 'class-validator';
import { addIcons } from 'ionicons';
import {
@ -82,11 +86,14 @@ import { GfValueComponent } from '../value/value.component';
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatMenuModule,
MatPaginatorModule,
MatSelectModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
ReactiveFormsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -103,6 +110,7 @@ export class GfActivitiesTableComponent
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToDeleteActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilterByType = false;
@Input() hasPermissionToOpenDetails = true;
@Input() locale = getLocale();
@Input() pageIndex: number;
@ -125,14 +133,24 @@ export class GfActivitiesTableComponent
@Output() pageChanged = new EventEmitter<PageEvent>();
@Output() selectedActivities = new EventEmitter<Activity[]>();
@Output() sortChanged = new EventEmitter<Sort>();
@Output() typesFilterChanged = new EventEmitter<string[]>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public readonly activityTypes: Record<ActivityType, string> = {
[ActivityType.BUY]: $localize`Buy`,
[ActivityType.DIVIDEND]: $localize`Dividend`,
[ActivityType.FEE]: $localize`Fee`,
[ActivityType.INTEREST]: $localize`Interest`,
[ActivityType.LIABILITY]: $localize`Liability`,
[ActivityType.SELL]: $localize`Sell`
};
public hasDrafts = false;
public hasErrors = false;
public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]);
public readonly dataSource = input.required<
MatTableDataSource<Activity> | undefined
@ -214,6 +232,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() {

16
libs/ui/src/lib/services/data.service.ts

@ -214,7 +214,8 @@ export class DataService {
skip,
sortColumn,
sortDirection,
take
take,
types
}: {
filters?: Filter[];
range?: DateRange;
@ -222,6 +223,7 @@ export class DataService {
sortColumn?: string;
sortDirection?: SortDirection;
take?: number;
types?: string[];
}): Observable<ActivitiesResponse> {
let params = this.buildFiltersAsQueryParams({ filters });
@ -245,6 +247,10 @@ export class DataService {
params = params.append('take', take);
}
if (types?.length) {
params = params.append('types', types.join(','));
}
return this.http.get<any>('/api/v1/activities', { params }).pipe(
map(({ activities, count }) => {
for (const activity of activities) {
@ -411,10 +417,12 @@ export class DataService {
public fetchExport({
activityIds,
filters
filters,
types
}: {
activityIds?: string[];
filters?: Filter[];
types?: string[];
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
@ -422,6 +430,10 @@ export class DataService {
params = params.append('activityIds', activityIds.join(','));
}
if (types?.length) {
params = params.append('types', types.join(','));
}
return this.http.get<ExportResponse>('/api/v1/export', {
params
});

2
prisma/migrations/20260321200654_add_index_on_order_type/migration.sql

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Order_type_idx" ON "Order"("type");

1
prisma/schema.prisma

@ -158,6 +158,7 @@ model Order {
@@index([accountId])
@@index([date])
@@index([isDraft])
@@index([type])
@@index([userId])
}

Loading…
Cancel
Save