From 166ba252273cda576ad4b78cf3c864a87eddf1ed Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:24:27 +0200 Subject: [PATCH 01/19] Delete workspace with all related files and results --- .../services/workspace-core.service.ts | 26 +++++++++++-- .../workspaces/workspaces.component.ts | 37 ++++++++++++++++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-core.service.ts b/apps/backend/src/app/database/services/workspace-core.service.ts index b19517984..0a088fdec 100644 --- a/apps/backend/src/app/database/services/workspace-core.service.ts +++ b/apps/backend/src/app/database/services/workspace-core.service.ts @@ -1,11 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Connection, In, Repository } from 'typeorm'; import Workspace from '../entities/workspace.entity'; import { WorkspaceInListDto } from '../../../../../../api-dto/workspaces/workspace-in-list-dto'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; import { CreateWorkspaceDto } from '../../../../../../api-dto/workspaces/create-workspace-dto'; import { AdminWorkspaceNotFoundException } from '../../exceptions/admin-workspace-not-found.exception'; +import FileUpload from '../entities/file_upload.entity'; +import Persons from '../entities/persons.entity'; @Injectable() export class WorkspaceCoreService { @@ -13,7 +15,8 @@ export class WorkspaceCoreService { constructor( @InjectRepository(Workspace) - private workspaceRepository: Repository + private workspaceRepository: Repository, + private connection: Connection ) {} async findAll(options?: { page: number; limit: number }): Promise<[WorkspaceInListDto[], number]> { @@ -91,8 +94,22 @@ export class WorkspaceCoreService { return; } this.logger.log(`Attempting to delete workspaces with IDs: ${ids.join(', ')}`); + + const queryRunner = this.connection.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { - const result = await this.workspaceRepository.delete(ids); + await queryRunner.manager.delete(FileUpload, { workspace_id: In(ids) }); + this.logger.log(`Deleted file uploads for workspaces with IDs: ${ids.join(', ')}`); + + await queryRunner.manager.delete(Persons, { workspace_id: In(ids) }); + this.logger.log(`Deleted persons for workspaces with IDs: ${ids.join(', ')}`); + + const result = await queryRunner.manager.delete(Workspace, { id: In(ids) }); + this.logger.log(`Deleted workspaces with IDs: ${ids.join(', ')}`); + + await queryRunner.commitTransaction(); if (result.affected && result.affected > 0) { this.logger.log(`Successfully deleted ${result.affected} workspace(s) with IDs: ${ids.join(', ')}`); @@ -100,8 +117,11 @@ export class WorkspaceCoreService { this.logger.warn(`No workspaces found with the specified IDs: ${ids.join(', ')}`); } } catch (error) { + await queryRunner.rollbackTransaction(); this.logger.error(`Failed to delete workspaces with IDs: ${ids.join(', ')}. Error: ${error.message}`, error.stack); throw error; + } finally { + await queryRunner.release(); } } } diff --git a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts index 26000b49b..bc16bba28 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts @@ -33,6 +33,8 @@ export class WorkspacesComponent { selectedWorkspaceId = 0; selectedWorkspaces : number[] = []; workspacesChanged: boolean = false; + isDeleting: boolean = false; + deleteStatus: string = ''; @ViewChild(MatSort) sort = new MatSort(); @@ -83,19 +85,44 @@ export class WorkspacesComponent { } deleteWorkspace(workspace_ids:number[]): void { + this.isDeleting = true; + const deleteSteps = [ + 'admin.deleting-workspace-starting', + 'admin.deleting-workspace-files', + 'admin.deleting-workspace-persons', + 'admin.deleting-workspace-finish' + ]; + + let stepIndex = 0; + const interval = setInterval(() => { + if (stepIndex < deleteSteps.length) { + this.deleteStatus = this.translateService.instant(deleteSteps[stepIndex]); + // eslint-disable-next-line no-plusplus + stepIndex++; + } else { + clearInterval(interval); + } + }, 1000); + this.backendService.deleteWorkspace(workspace_ids).subscribe( respOk => { + clearInterval(interval); if (respOk) { - this.snackBar.open( - this.translateService.instant('admin.workspace-deleted'), - '', - { duration: 1000 }); - this.workspacesChanged = true; + this.deleteStatus = this.translateService.instant('admin.deleting-workspace-success'); + setTimeout(() => { + this.snackBar.open( + this.translateService.instant('admin.workspace-deleted'), + '', + { duration: 1000 }); + this.workspacesChanged = true; + this.isDeleting = false; + }, 1000); } else { this.snackBar.open( this.translateService.instant('admin.workspace-not-deleted'), this.translateService.instant('error'), { duration: 1000 }); + this.isDeleting = false; } } ); From dc8e13ff384e88014abf67208551eabe6592db4e Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 20 Jun 2025 08:48:28 +0200 Subject: [PATCH 02/19] Use queryParams to deleteWorkspace --- .../admin/workspace/workspace.controller.ts | 29 ++++++++++++------- .../src/app/services/backend.service.ts | 8 +++-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.ts b/apps/backend/src/app/admin/workspace/workspace.controller.ts index 5f7f237ba..aa795816a 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.ts @@ -3,13 +3,23 @@ import { Body, Controller, Delete, - Get, Param, Patch, - Post, Query, UseGuards + Get, + ParseArrayPipe, + Patch, + Post, + Query, + UseGuards } from '@nestjs/common'; import { ApiBadRequestResponse, - ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, - ApiParam, ApiQuery, ApiTags + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, ApiParam, + ApiQuery, + ApiTags } from '@nestjs/swagger'; import { logger } from 'nx/src/utils/logger'; import { WorkspaceInListDto } from '../../../../../../api-dto/workspaces/workspace-in-list-dto'; @@ -104,16 +114,14 @@ export class WorkspaceController { } } - // TODO: use query params - // TODO: use ParseIntPipe - @Delete(':ids') + @Delete() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Delete workspaces', description: 'Deletes one or more workspaces by their IDs (separated by semicolons)' }) - @ApiParam({ + @ApiQuery({ name: 'ids', description: 'Semicolon-separated list of workspace IDs to delete', example: '1;2;3', @@ -123,9 +131,8 @@ export class WorkspaceController { @ApiNotFoundResponse({ description: 'Admin workspace not found.' }) @ApiBadRequestResponse({ description: 'Invalid workspace IDs' }) @ApiTags('admin workspaces') - async remove(@Param('ids') ids: string): Promise { - const idsAsNumberArray: number[] = ids.split(';').map(idString => parseInt(idString, 10)); - return this.workspaceCoreService.remove(idsAsNumberArray); + async remove(@Query('ids', new ParseArrayPipe({ items: Number, separator: ';' })) ids: number[]): Promise { + return this.workspaceCoreService.remove(ids); } @Patch() diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index e5946db3d..93c56fdbc 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -198,11 +198,13 @@ export class BackendService { ); } - // Todo: Use queryParams for ids deleteWorkspace(ids: number[]): Observable { + const params = new HttpParams().set('ids', ids.join(';')); return this.http - .delete(`${this.serverUrl}admin/workspace/${ids.join(';')}`, - { headers: this.authHeader }) + .delete(`${this.serverUrl}admin/workspace`, { + headers: this.authHeader, + params + }) .pipe( catchError(() => of(false)), map(() => true) From 4ccaef6a35265044f1b861fa11e22e68d8d0faad Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 20 Jun 2025 09:10:59 +0200 Subject: [PATCH 03/19] Prevent removal of all id or user id with custom exceptions --- .../src/app/admin/users/users.controller.ts | 11 ++++---- .../app/database/services/users.service.ts | 28 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/app/admin/users/users.controller.ts b/apps/backend/src/app/admin/users/users.controller.ts index f9bcbe6cb..d433d443c 100755 --- a/apps/backend/src/app/admin/users/users.controller.ts +++ b/apps/backend/src/app/admin/users/users.controller.ts @@ -1,6 +1,6 @@ import { Body, - Controller, Delete, Get, Param, Patch, Post, UseGuards + Controller, Delete, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiMethodNotAllowedResponse, @@ -120,13 +120,14 @@ export class UsersController { @ApiBadRequestResponse({ description: 'Invalid user IDs' }) @ApiNotFoundResponse({ description: 'One or more users not found' }) @ApiTags('admin users') - async remove(@Param('ids') ids: string): Promise { + async remove(@Param('ids') ids: string, @Req() req): Promise { const idsAsNumberArray: number[] = []; ids.split(';').forEach(s => idsAsNumberArray.push(parseInt(s, 10))); - return this.usersService.remove(idsAsNumberArray); + return this.usersService.removeIds(idsAsNumberArray, req.user.id); } @Delete() + @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Delete users by query', @@ -144,8 +145,8 @@ export class UsersController { @ApiBadRequestResponse({ description: 'Invalid user IDs' }) @ApiNotFoundResponse({ description: 'One or more users not found' }) @ApiMethodNotAllowedResponse({ description: 'Active admin user must not be deleted' }) - async removeIds(ids: number[]): Promise { - return this.usersService.removeIds(ids); + async removeIds(@Query('id') ids: number[], @Req() req): Promise { + return this.usersService.removeIds(ids, req.user.id); } @Post(':userId/workspaces') diff --git a/apps/backend/src/app/database/services/users.service.ts b/apps/backend/src/app/database/services/users.service.ts index de4263cad..956f09ea4 100755 --- a/apps/backend/src/app/database/services/users.service.ts +++ b/apps/backend/src/app/database/services/users.service.ts @@ -1,4 +1,6 @@ -import { Injectable, Logger, MethodNotAllowedException } from '@nestjs/common'; +import { + Injectable, Logger, BadRequestException, ForbiddenException +} from '@nestjs/common'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import User from '../entities/user.entity'; @@ -189,19 +191,25 @@ export class UsersService { await this.usersRepository.delete(id); } - removeIds(ids: number[]) { - // TODO: Sich selbst bzw. alle löschen verhindern? - if (ids && ids.length) { - ids.forEach(id => this.remove(id)); + async removeIds(ids: number[], currentUserId: number): Promise { + if (!ids || ids.length === 0) { + throw new BadRequestException('No user IDs were provided for deletion.'); } - // TODO: Eigene Exception mit Custom-Parametern - throw new MethodNotAllowedException(); + + if (ids.includes(currentUserId)) { + throw new ForbiddenException('A user cannot delete themselves.'); + } + + const totalUsers = await this.usersRepository.count(); + if (ids.length >= totalUsers) { + throw new ForbiddenException('All users cannot be deleted at once.'); + } + + await this.usersRepository.delete(ids); } async createKeycloakUser(keycloakUser: CreateUserDto): Promise { const { username, identity, issuer } = keycloakUser; - - // Search for an existing user by either username or a combination of identity and issuer const existingUser = await this.usersRepository.findOne({ where: [ { username }, @@ -213,12 +221,10 @@ export class UsersService { }); if (existingUser) { - // Prepare fields to update if the provided identity or issuer has changed const updatedFields: Partial = {}; if (identity && existingUser.identity !== identity) updatedFields.identity = identity; if (issuer && existingUser.issuer !== issuer) updatedFields.issuer = issuer; - // Only update the database if there are fields to update if (Object.keys(updatedFields).length > 0) { await this.usersRepository.update({ id: existingUser.id }, updatedFields); this.logger.log(`Updating existing user: ${JSON.stringify({ ...existingUser, ...updatedFields })}`); From 01b6dd87e30bfb18aa132c2bad62aa7d0406c245 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 20 Jun 2025 09:13:23 +0200 Subject: [PATCH 04/19] Update to latest Jest snapshotFormat --- jest.preset.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/jest.preset.js b/jest.preset.js index 3117ab333..27632c98f 100755 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,15 +1,5 @@ const nxPreset = require('@nx/jest/preset').default; module.exports = { - ...nxPreset, - /* TODO: Update to latest Jest snapshotFormat - * By default Nx has kept the older style of Jest Snapshot formats - * to prevent breaking of any existing tests with snapshots. - * It's recommend you update to the latest format. - * You can do this by removing snapshotFormat property - * and running tests with --update-snapshot flag. - * Example: "nx affected --targets=test --update-snapshot" - * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format - */ - snapshotFormat: { escapeString: true, printBasicPrototype: true } + ...nxPreset }; From 6d2ce3162014744d86d6025ec5e5febab7365386 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sat, 21 Jun 2025 11:12:17 +0200 Subject: [PATCH 05/19] Add test-files pagination and filtering --- .../workspace/workspace-files.controller.ts | 27 +++- .../services/workspace-files.service.ts | 76 +++++++--- .../src/app/services/backend.service.ts | 14 +- .../test-files/test-files.component.html | 7 + .../test-files/test-files.component.ts | 132 ++++-------------- .../ws-admin/ws-admin.component.html | 4 +- .../ws-admin/ws-admin.component.scss | 11 ++ 7 files changed, 141 insertions(+), 130 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts index 3f076489d..5fdc82d18 100644 --- a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts @@ -47,6 +47,24 @@ export class WorkspaceFilesController { description: 'Number of items per page', type: Number }) + @ApiQuery({ + name: 'fileType', + required: false, + description: 'Filter by file type', + type: String + }) + @ApiQuery({ + name: 'fileSize', + required: false, + description: 'Filter by file size range (e.g. 0-10KB, 10KB-100KB, 100KB-1MB, 1MB-10MB, 10MB+)', + type: String + }) + @ApiQuery({ + name: 'searchText', + required: false, + description: 'Filter by search text (filename, type, date)', + type: String + }) @ApiOkResponse({ description: 'Files retrieved successfully.', schema: { @@ -69,7 +87,10 @@ export class WorkspaceFilesController { async findFiles( @Param('workspace_id') workspace_id: number, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @Query('fileType') fileType?: string, + @Query('fileSize') fileSize?: string, + @Query('searchText') searchText?: string ): Promise<{ data: FilesDto[]; total: number; page: number; limit: number }> { if (!workspace_id || workspace_id <= 0) { throw new BadRequestException( @@ -77,7 +98,9 @@ export class WorkspaceFilesController { ); } try { - const [files, total] = await this.workspaceFilesService.findFiles(workspace_id, { page, limit }); + const [files, total] = await this.workspaceFilesService.findFiles(workspace_id, { + page, limit, fileType, fileSize, searchText + }); return { data: files, total, diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index 0ac98e57b..1ea0a801c 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -65,35 +65,65 @@ export class WorkspaceFilesService { private fileUploadRepository: Repository ) {} - async findFiles(workspaceId: number, options?: { page: number; limit: number }): Promise<[FilesDto[], number]> { + async findFiles( + workspaceId: number, + options?: { page: number; limit: number; fileType?: string; fileSize?: string; searchText?: string } + ): Promise<[FilesDto[], number]> { this.logger.log(`Fetching test files for workspace: ${workspaceId}`); + const { + page = 1, limit = 20, fileType, fileSize, searchText + } = options || {}; + const MAX_LIMIT = 10000; + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); + + // QueryBuilder für flexible Filterung + let qb = this.fileUploadRepository.createQueryBuilder('file') + .where('file.workspace_id = :workspaceId', { workspaceId }); + + if (fileType) { + qb = qb.andWhere('file.file_type = :fileType', { fileType }); + } - if (options) { - const { page, limit } = options; - const MAX_LIMIT = 10000; - const validPage = Math.max(1, page); - const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); - - const [files, total] = await this.fileUploadRepository.findAndCount({ - where: { workspace_id: workspaceId }, - select: ['id', 'filename', 'file_id', 'file_size', 'file_type', 'created_at'], - skip: (validPage - 1) * validLimit, - take: validLimit, - order: { created_at: 'DESC' } - }); + if (fileSize) { + // fileSize-Filter: z.B. '0-10KB', '10KB-100KB', '100KB-1MB', '1MB-10MB', '10MB+' + const KB = 1024; + const MB = 1024 * KB; + switch (fileSize) { + case '0-10KB': + qb = qb.andWhere('file.file_size < :max', { max: 10 * KB }); + break; + case '10KB-100KB': + qb = qb.andWhere('file.file_size >= :min AND file.file_size < :max', { min: 10 * KB, max: 100 * KB }); + break; + case '100KB-1MB': + qb = qb.andWhere('file.file_size >= :min AND file.file_size < :max', { min: 100 * KB, max: 1 * MB }); + break; + case '1MB-10MB': + qb = qb.andWhere('file.file_size >= :min AND file.file_size < :max', { min: 1 * MB, max: 10 * MB }); + break; + case '10MB+': + qb = qb.andWhere('file.file_size >= :min', { min: 10 * MB }); + break; + } + } - this.logger.log(`Found ${files.length} files (page ${validPage}, limit ${validLimit}, total ${total}).`); - return [files, total]; + if (searchText) { + const search = `%${searchText.toLowerCase()}%`; + qb = qb.andWhere( + '(LOWER(file.filename) LIKE :search OR LOWER(file.file_type) LIKE :search OR TO_CHAR(file.created_at, \'DD.MM.YYYY HH24:MI\') ILIKE :search)', + { search } + ); } - const files = await this.fileUploadRepository.find({ - where: { workspace_id: workspaceId }, - select: ['id', 'filename', 'file_id', 'file_size', 'file_type', 'created_at'], - order: { created_at: 'DESC' } - }); + qb = qb.select(['file.id', 'file.filename', 'file.file_id', 'file.file_size', 'file.file_type', 'file.created_at']) + .orderBy('file.created_at', 'DESC') + .skip((validPage - 1) * validLimit) + .take(validLimit); - this.logger.log(`Found ${files.length} files.`); - return [files, files.length]; + const [files, total] = await qb.getManyAndCount(); + this.logger.log(`Found ${files.length} files (page ${validPage}, limit ${validLimit}, total ${total}).`); + return [files, total]; } async deleteTestFiles(workspace_id: number, fileIds: string[]): Promise { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 93c56fdbc..0d2113487 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -477,10 +477,20 @@ export class BackendService { { headers: this.authHeader }); } - getFilesList(workspaceId: number, page: number = 1, limit: number = 10000): Observable> { - const params = new HttpParams() + getFilesList( + workspaceId: number, + page: number = 1, + limit: number = 10000, + fileType?: string, + fileSize?: string, + searchText?: string + ): Observable> { + let params = new HttpParams() .set('page', page.toString()) .set('limit', limit.toString()); + if (fileType) params = params.set('fileType', fileType); + if (fileSize) params = params.set('fileSize', fileSize); + if (searchText) params = params.set('searchText', searchText); return this.http.get>( `${this.serverUrl}admin/workspace/${workspaceId}/files`, diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html index fe5dde39c..5c98bf879 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html @@ -143,6 +143,13 @@

Keine Test-Dateien vorhanden. Laden Sie Dateien hoch, um zu beginnen.

} + + } diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts index cfdbc4d7f..7286a8116 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts @@ -25,6 +25,7 @@ import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatOption, MatSelect } from '@angular/material/select'; import { Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; +import { MatPaginator } from '@angular/material/paginator'; import { FilesValidationDialogComponent } from '../files-validation-result/files-validation.component'; import { TestCenterImportComponent } from '../test-center-import/test-center-import.component'; import { ResourcePackagesDialogComponent } from '../resource-packages-dialog/resource-packages-dialog.component'; @@ -70,7 +71,8 @@ import { FileDownloadDto } from '../../../../../../../api-dto/files/file-downloa MatFormField, MatLabel, MatSelect, - MatOption + MatOption, + MatPaginator ] }) export class TestFilesComponent implements OnInit, OnDestroy { @@ -105,19 +107,11 @@ export class TestFilesComponent implements OnInit, OnDestroy { private textFilterChanged: Subject = new Subject(); private textFilterSubscription: Subscription | undefined; - // Definition der Größeneinheiten und ihrer Multiplikatoren in Bytes - private readonly SIZES_UNITS = { - bytes: 1, - b: 1, - kb: 1024, - kib: 1024, - mb: 1024 ** 2, - mib: 1024 ** 2, - gb: 1024 ** 3, - gib: 1024 ** 3, - tb: 1024 ** 4, - tib: 1024 ** 4 - }; + + // Pagination variables + page: number = 1; + limit: number = 100; + total: number = 0; ngOnInit(): void { this.loadTestFiles(false); @@ -160,21 +154,25 @@ export class TestFilesComponent implements OnInit, OnDestroy { loadTestFiles(forceReload: boolean): void { this.isLoading = true; this.isValidating = false; - if (forceReload || !this.appService.workspaceData?.testFiles.data.length) { - this.backendService.getFilesList(this.appService.selectedWorkspaceId) - .subscribe(files => { - this.updateTable(files); - }); - } else { - this.updateTable(this.appService.workspaceData.testFiles || []); - } + this.backendService.getFilesList( + this.appService.selectedWorkspaceId, + this.page, + this.limit, + this.selectedFileType, + this.selectedFileSize, + this.textFilterValue + ).subscribe(response => { + this.total = response.total; + this.page = response.page; + this.limit = response.limit; + this.updateTable(response); + }); } /** Updates the table data source and stops spinner */ private updateTable(files: { data: FilesInListDto[] }): void { this.dataSource = new MatTableDataSource(files.data); this.extractFileTypes(files.data); - this.setupFilterPredicate(); this.isLoading = false; } @@ -190,84 +188,10 @@ export class TestFilesComponent implements OnInit, OnDestroy { this.fileTypes.unshift(''); } - /** Sets up custom filter predicate for the data source */ - private setupFilterPredicate(): void { - this.dataSource.filterPredicate = (data: FilesInListDto, filter: string) => { - const filterObj = JSON.parse(filter || '{}'); - // Text filter - check if any of the fields contain the search text - const textMatch = !filterObj.text || ( - (data.filename && data.filename.toLowerCase().includes(filterObj.text.toLowerCase())) || - (data.file_type && data.file_type.toLowerCase().includes(filterObj.text.toLowerCase())) || - (data.created_at && new Date(data.created_at).toLocaleDateString().includes(filterObj.text.toLowerCase())) - ); - // File type filter - const typeMatch = !filterObj.fileType || - (data.file_type && data.file_type === filterObj.fileType); - // File size filter - const sizeMatch = this.isFileSizeInRange(data.file_size, filterObj.fileSize); - return (textMatch && typeMatch && sizeMatch) as boolean; - }; - } - - /** Parses a file size string (e.g., "10 KB", "1.5 MB") into bytes */ - private parseFileSizeToBytes(fileSizeStr: string | undefined | null): number | null { - if (!fileSizeStr) return 0; // Interpret empty or null as 0 Bytes - const str = String(fileSizeStr).trim().toLowerCase(); - if (str === '0' || str === '0 bytes' || str === '0b') return 0; - const match = str.match(/^([\d.]+)\s*([a-z]+)?$/); - if (match) { - const value = parseFloat(match[1]); - const unit = match[2] || 'bytes'; - if (Number.isNaN(value)) return null; // Invalid number part - const multiplier = this.SIZES_UNITS[unit as keyof typeof this.SIZES_UNITS]; - if (multiplier !== undefined) { - return value * multiplier; - } - if (match[2]) return null; - return value; // Value is in bytes - } - const numericValue = parseFloat(str); - if (!Number.isNaN(numericValue) && /^[\d.]+$/.test(str)) { - return numericValue; - } - return null; // Unable to parse - } - - /** Checks if a file size is within the selected range */ - private isFileSizeInRange(fileSize: string | undefined, range: string): boolean { - const sizeInBytes = this.parseFileSizeToBytes(fileSize); - if (sizeInBytes === null) { - return range === '' || range === undefined; - } - const KB = 1024; - const MB = 1024 * KB; - switch (range) { - case '0-10KB': // Less than 10KB - return sizeInBytes < 10 * KB; - case '10KB-100KB': // 10KB to 100KB (exclusive of 100KB) - return sizeInBytes >= 10 * KB && sizeInBytes < 100 * KB; - case '100KB-1MB': // 100KB to 1MB (exclusive of 1MB) - return sizeInBytes >= 100 * KB && sizeInBytes < 1 * MB; - case '1MB-10MB': // 1MB to 10MB (exclusive of 10MB) - return sizeInBytes >= 1 * MB && sizeInBytes < 10 * MB; - case '10MB+': // 10MB or more - return sizeInBytes >= 10 * MB; - default: // No range selected or unknown range, so it matches - return true; - } - } - /** Applies all filters */ applyFilters(): void { - const filterObj = { - text: this.textFilterValue, - fileType: this.selectedFileType, - fileSize: this.selectedFileSize - }; - this.dataSource.filter = JSON.stringify(filterObj); - if (this.dataSource.paginator) { - this.dataSource.paginator.firstPage(); - } + this.page = 1; + this.loadTestFiles(true); } /** Handles text filter changes */ @@ -281,10 +205,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { this.textFilterValue = ''; this.selectedFileType = ''; this.selectedFileSize = ''; - // Direkt applyFilters aufrufen oder auch über den Subject, je nach gewünschtem Verhalten this.applyFilters(); - // Wenn clearFilters auch debounced werden soll: - // this.textFilterChanged.next(this.textFilterValue); } /** Handles file selection for upload */ @@ -441,4 +362,11 @@ export class TestFilesComponent implements OnInit, OnDestroy { } }); } + + /** Wird vom MatPaginator aufgerufen */ + onPageChange(event: any): void { + this.page = event.pageIndex + 1; + this.limit = event.pageSize; + this.loadTestFiles(true); + } } diff --git a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.html b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.html index c8f1ca4be..6584fec3e 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.html +++ b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.html @@ -5,6 +5,7 @@ } @else if (accessLevel === 2){ } @else if (accessLevel > 2 || authData.isAdmin){ +
- + +
} diff --git a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.scss b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.scss index ee88d6c67..f5240cd1d 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.scss +++ b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.scss @@ -24,3 +24,14 @@ a{ .navigation{ margin-bottom: 10px; } + +.flex-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.router-panel { + flex: 1; + overflow: auto; +} From 21f2c6a880c448e03e897dde2fb52f4797bdf3e2 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sat, 21 Jun 2025 15:04:10 +0200 Subject: [PATCH 06/19] Improve of view of not coded responses --- .../workspace-test-results.service.ts | 9 ++- .../coding-management.component.html | 71 ++++--------------- .../coding-management.component.ts | 9 ++- 3 files changed, 26 insertions(+), 63 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 9cd13efe7..c63620ce7 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -273,12 +273,17 @@ export class WorkspaceTestResultsService { .leftJoinAndSelect('response.unit', 'unit') .leftJoinAndSelect('unit.booklet', 'booklet') .leftJoinAndSelect('booklet.person', 'person') - .leftJoinAndSelect('booklet.bookletinfo', 'bookletinfo') // Diese Relation wird geladen, wie im Originalcode + .leftJoinAndSelect('booklet.bookletinfo', 'bookletinfo') .where('response.status = :constStatus', { constStatus: 'VALUE_CHANGED' }) - .andWhere('response.codedStatus = :statusParam', { statusParam: status }) .andWhere('person.workspace_id = :workspace_id_param', { workspace_id_param: workspace_id }) .orderBy('response.id', 'ASC'); + if (status === 'null') { + queryBuilder.andWhere('response.codedStatus IS NULL'); + } else { + queryBuilder.andWhere('response.codedStatus = :statusParam', { statusParam: status }); + } + if (options) { const { page, limit } = options; const MAX_LIMIT = 500; diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.html b/apps/frontend/src/app/coding/coding-managment/coding-management.component.html index 295cd18d8..3dda46247 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.html +++ b/apps/frontend/src/app/coding/coding-managment/coding-management.component.html @@ -32,61 +32,16 @@

Kodierstatistiken

Gesamtanzahl der gegebenen Antworten: {{ codingStatistics.totalResponses }} - @if (codingStatistics.statusCounts['INVALID'] !== undefined) { + @for (status of getStatuses(); track status) {
- } - @if (codingStatistics.statusCounts['CODING_INCOMPLETE'] !== undefined) { -
- Anzahl der Antworten mit Status CODING_INCOMPLETE: - - {{ codingStatistics.statusCounts['CODING_INCOMPLETE'] }} - ({{ getStatusPercentage('CODING_INCOMPLETE') }}%) - - - visibility - Anzeigen - -
- } - @if (codingStatistics.statusCounts['NOT_REACHED'] !== undefined) { -
- Anzahl der Antworten mit Status NOT_REACHED: - - {{ codingStatistics.statusCounts['NOT_REACHED'] }} - ({{ getStatusPercentage('NOT_REACHED') }}%) - - - visibility - Anzeigen - -
- } - @if (codingStatistics.statusCounts['INTENDED_INCOMPLETE'] !== undefined) { -
- Anzahl der Antworten mit Status INTENDED_INCOMPLETE: - - {{ codingStatistics.statusCounts['INTENDED_INCOMPLETE'] }} - ({{ getStatusPercentage('INTENDED_INCOMPLETE') }}%) + + @if (status === 'null') { + unkodierte Antworten: + warning + } @else { + Anzahl der Antworten mit Status {{ status }}: + } - - visibility - Anzeigen - -
- } - @for (status of getOtherStatuses(); track status) { -
- Anzahl der Antworten mit Status {{ status }}: {{ codingStatistics.statusCounts[status] }} ({{ getStatusPercentage(status) }}%) @@ -136,7 +91,13 @@

Kodierstatistiken

Kodierdaten

@if (currentStatusFilter) {
-

Antworten mit Status: {{ currentStatusFilter }}

+

+ @if (currentStatusFilter === 'null') { + Unkodierte Antworten + } @else { + Antworten mit Status: {{ currentStatusFilter }} + } +

}
@@ -168,7 +129,6 @@

Antworten mit Status: {{ currentStatusFilter }} } @if (element.id !== 0) { - @if (column === 'actions') { +
+ + +
+ } @else if (column === 'unitname') { + + {{ element[column] }} + } @else { + @if (data.isJson) { +
{{ formatJson(data.content) }}
+ } @else if (data.isXml) { +
{{ formatXml(data.content) }}
+ } @else { +
{{ data.content }}
+ } + + + + + + + `, + styles: [` + .content-container { + max-height: 80vh; + overflow-y: auto; + padding: 1rem; + border: 1px solid #e0e0e0; + border-radius: 4px; + background-color: #f9f9f9; + } + + pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + } + + .json-content { + font-family: 'Consolas', 'Monaco', monospace; + color: #333; + } + + .xml-content { + font-family: 'Consolas', 'Monaco', monospace; + color: #333; + } + `] +}) +export class ContentDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DialogData + ) {} + + close(): void { + this.dialogRef.close(); + } + + formatJson(jsonString: string): string { + try { + const obj = JSON.parse(jsonString); + return JSON.stringify(obj, null, 2); + } catch (e) { + return jsonString; + } + } + + formatXml(xmlString: string): string { + return xmlString + .replace(/>\n<') + .replace(/>\s*<\/(\w+)>/g, '>\n') + .replace(/<(\w+)([^>]*)\/>/g, '<$1$2/>\n'); + } +} diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 0d2113487..e2f82915c 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -686,4 +686,23 @@ export class BackendService { catchError(() => of(-1)) ); } + + getCodingSchemeFile(workspaceId: number, codingSchemeRef: string): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/files/coding-scheme/${codingSchemeRef}`, + { headers: this.authHeader } + ).pipe( + catchError(() => of(null)) + ); + } + + getUnitContentXml(workspaceId: number, unitId: number): Observable { + return this.http.get<{ content: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}/content`, + { headers: this.authHeader } + ).pipe( + map(response => response.content), + catchError(() => of(null)) + ); + } } From bec4e8bf00c908f1811a14282fcf976b8eb5c505 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:59:03 +0200 Subject: [PATCH 08/19] Display file content --- .../coding-management.component.ts | 2 +- .../content-dialog.component.ts | 0 .../test-files/test-files.component.html | 9 +++++++++ .../test-files/test-files.component.ts | 20 +++++++++++++++++-- 4 files changed, 28 insertions(+), 3 deletions(-) rename apps/frontend/src/app/{coding => shared/dialogs}/content-dialog/content-dialog.component.ts (100%) diff --git a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts index 68b568ffa..6ebade48b 100755 --- a/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/coding-managment/coding-management.component.ts @@ -30,7 +30,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; import { MatDialog } from '@angular/material/dialog'; -import { ContentDialogComponent } from '../content-dialog/content-dialog.component'; +import { ContentDialogComponent } from '../../shared/dialogs/content-dialog/content-dialog.component'; import { BackendService, CodingListItem } from '../../services/backend.service'; import { AppService } from '../../services/app.service'; import { CodingStatistics } from '../../../../../../api-dto/coding/coding-statistics'; diff --git a/apps/frontend/src/app/coding/content-dialog/content-dialog.component.ts b/apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts similarity index 100% rename from apps/frontend/src/app/coding/content-dialog/content-dialog.component.ts rename to apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html index 5c98bf879..7a1ec3e7b 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html @@ -134,6 +134,15 @@ {{element.created_at | date: 'dd.MM.yyyy HH:mm'}} + + + + + + + diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts index 7286a8116..5b7c80b10 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts @@ -39,6 +39,7 @@ import { FileSizePipe } from '../../../shared/pipes/filesize.pipe'; import { FilesInListDto } from '../../../../../../../api-dto/files/files-in-list.dto'; import { FileValidationResultDto } from '../../../../../../../api-dto/files/file-validation-result.dto'; import { FileDownloadDto } from '../../../../../../../api-dto/files/file-download.dto'; +import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/content-dialog.component'; @Component({ selector: 'coding-box-test-files', @@ -72,7 +73,8 @@ import { FileDownloadDto } from '../../../../../../../api-dto/files/file-downloa MatLabel, MatSelect, MatOption, - MatPaginator + MatPaginator, + ContentDialogComponent ] }) export class TestFilesComponent implements OnInit, OnDestroy { @@ -82,7 +84,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); - displayedColumns: string[] = ['selectCheckbox', 'filename', 'file_size', 'file_type', 'created_at']; + displayedColumns: string[] = ['selectCheckbox', 'filename', 'file_size', 'file_type', 'created_at', 'actions']; dataSource!: MatTableDataSource; tableCheckboxSelection = new SelectionModel(true, []); isLoading = false; @@ -369,4 +371,18 @@ export class TestFilesComponent implements OnInit, OnDestroy { this.limit = event.pageSize; this.loadTestFiles(true); } + + showFileContent(file: FilesInListDto): void { + this.backendService.downloadFile(this.appService.selectedWorkspaceId, file.id).subscribe(fileData => { + const decodedContent = atob(fileData.base64Data); + this.dialog.open(ContentDialogComponent, { + width: '800px', + height: '800px', + data: { + title: file.filename, + content: decodedContent + } + }); + }); + } } From ec6af09375907ba25302b68e6b8f2dae7c603ce0 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:05:01 +0200 Subject: [PATCH 09/19] Use another algorithm to extract unit definition variable info --- .../services/workspace-coding.service.ts | 28 ++++-- .../app/utils/voud/extractVariableLocation.ts | 98 +++++++++++++++++++ .../{sample.voud.old => sample.voud.old.json} | 0 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/app/utils/voud/extractVariableLocation.ts rename apps/backend/src/app/utils/voud/{sample.voud.old => sample.voud.old.json} (100%) diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index 9355d6d51..5f3c7fc11 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -12,6 +12,7 @@ import { Booklet } from '../entities/booklet.entity'; import { ResponseEntity } from '../entities/response.entity'; import { CodingStatistics } from './shared-types'; import { prepareDefinition } from '../../utils/voud/transform'; +import { extractVariableLocation } from '../../utils/voud/extractVariableLocation'; @Injectable() export class WorkspaceCodingService { @@ -361,13 +362,20 @@ export class WorkspaceCodingService { const respDefinition = { definition: voudFile.data }; - const transformResult = prepareDefinition(respDefinition); - const variablePageInfo = transformResult.variablePages.find( + // const transformResult = prepareDefinition(respDefinition); + const variableLocation = extractVariableLocation([respDefinition]); + const variablePageInfo = variableLocation[0].variable_pages.find( pageInfo => pageInfo.variable_ref === response.variableid ); + const variablePageAlwaysVisible = variableLocation[0].variable_pages.find( + pageInfo => pageInfo.variable_page_always_visible === true + ); if (variablePageInfo) { - variablePage = variablePageInfo.variable_page.toString(); + if (variablePageAlwaysVisible && variablePageInfo.variable_page_always_visible === true) { + variablePage = (variablePageInfo.variable_path.pages - 1).toString(); + } + variablePage = variablePageInfo?.variable_path?.pages.toString(); } this.logger.log(`Processed VOUD file for unit ${unitKey}, variable ${response.variableid}, page ${variablePage}`); @@ -436,14 +444,20 @@ export class WorkspaceCodingService { const respDefinition = { definition: voudFile.data }; - const transformResult = prepareDefinition(respDefinition); - - const variablePageInfo = transformResult.variablePages.find( + // const transformResult = prepareDefinition(respDefinition); + const variableLocation = extractVariableLocation([respDefinition]); + const variablePageInfo = variableLocation[0].variable_pages.find( pageInfo => pageInfo.variable_ref === response.variableid ); + const variablePageAlwaysVisible = variableLocation[0].variable_pages.find( + pageInfo => pageInfo.variable_page_always_visible === true + ); if (variablePageInfo) { - variablePage = variablePageInfo.variable_page.toString(); + if (variablePageAlwaysVisible && variablePageInfo.variable_page_always_visible === true) { + variablePage = (variablePageInfo.variable_path.pages - 1).toString(); + } + variablePage = variablePageInfo?.variable_path?.pages.toString(); } this.logger.log(`Processed VOUD file for unit ${unitKey}, variable ${response.variableid}, page ${variablePage}`); diff --git a/apps/backend/src/app/utils/voud/extractVariableLocation.ts b/apps/backend/src/app/utils/voud/extractVariableLocation.ts new file mode 100644 index 000000000..0925ec6d5 --- /dev/null +++ b/apps/backend/src/app/utils/voud/extractVariableLocation.ts @@ -0,0 +1,98 @@ +function collectIdsWithKeyedPaths( + node, + path = {}, + collected = [], + visibility = null, + skipCollect = false +) { + if (Array.isArray(node)) { + node.forEach((child, index) => { + collectIdsWithKeyedPaths(child, path, collected, visibility, skipCollect); + }); + return collected; + } + + if (typeof node === 'object' && node !== null) { + // If at a 'page' level object, update visibility + if ('alwaysVisible' in node && 'sections' in node) { + visibility = node.alwaysVisible; + } + + // Only collect if not under a skipped key + if ('id' in node && !skipCollect) { + collected.push({ + id: node.id, + markingPanels: node.markingPanels, + connectedTo: node.connectedTo, + alwaysVisible: visibility, + path: { ...path } + }); + } + + // eslint-disable-next-line guard-for-in + for (const key in node) { + const value = node[key]; + const shouldSkip = + skipCollect || ['value', 'visibilityRules'].includes(key); + + if (Array.isArray(value)) { + value.forEach((child, index) => { + const newPath = { ...path, [key]: index }; + collectIdsWithKeyedPaths( + child, + newPath, + collected, + visibility, + shouldSkip + ); + }); + } else if (typeof value === 'object' && value !== null) { + const newPath = { ...path, [key]: 0 }; // object branch, no index + collectIdsWithKeyedPaths( + value, + newPath, + collected, + visibility, + shouldSkip + ); + } + } + } + + return collected; +} + +function findDependencies(data) { + return data.map((currentObj, _, arr) => { + const connectedIds = + currentObj.connectedTo || currentObj.markingPanels || []; + + const dependencies = connectedIds.flatMap(depId => arr + .filter(({ id }) => id === depId) + .map(match => ({ + variable_dependency_ref: match.id, + variable_dependency_path: match.path, + variable_dependency_page_always_visible: match.alwaysVisible + }))); + + return { + variable_ref: currentObj.id, + variable_path: currentObj.path, + variable_page_always_visible: currentObj.alwaysVisible, + variable_dependencies: dependencies + }; + }); +} + +export const extractVariableLocation = function (definitions:{ definition: string }[]): any { + return definitions.map((unit:any) => { + const definitionParsed = JSON.parse(unit.definition); + + const data = collectIdsWithKeyedPaths(definitionParsed); + unit.variable_pages = findDependencies(data); + + delete unit.definition; + + return unit; + }); +}; diff --git a/apps/backend/src/app/utils/voud/sample.voud.old b/apps/backend/src/app/utils/voud/sample.voud.old.json similarity index 100% rename from apps/backend/src/app/utils/voud/sample.voud.old rename to apps/backend/src/app/utils/voud/sample.voud.old.json From de442dc4ba8feae93b106776c409624c01d14227 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:24:16 +0200 Subject: [PATCH 10/19] Fix filter text search input in test-files component --- .../test-files/test-files.component.html | 104 +++++++++--------- 1 file changed, 50 insertions(+), 54 deletions(-) diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html index 7a1ec3e7b..3283c80ae 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.html @@ -29,60 +29,57 @@ [hidden]="true" (change)="onFileSelected($event.currentTarget)"/> - @if (isLoading) { -
- - @if (isValidating) { -

Validierung wird durchgeführt...

- } - @if (!isValidating) { -

Dateiliste wird geladen...

- } -
- } - - - @if (!isLoading) { -
-
-
- - - -
- - {{'file-upload.file_type' | translate}} - - Alle Dateitypen - @for (type of fileTypes; track type) { - - {{type}} - - } - - - - - {{'file-upload.file_size' | translate}} - - @for (range of fileSizeRanges; track range) { - - {{range.display}} - - } - - - - -
+
+
+
+ + + +
+ + {{'file-upload.file_type' | translate}} + + Alle Dateitypen + @for (type of fileTypes; track type) { + + {{type}} + + } + + + + + {{'file-upload.file_size' | translate}} + + @for (range of fileSizeRanges; track range) { + + {{range.display}} + + } + + + +
+
+ @if (isLoading) { +
+ + @if (isValidating) { +

Validierung wird durchgeführt...

+ } + @if (!isValidating) { +

Dateiliste wird geladen...

+ } +
+ } @else {
@@ -159,7 +156,6 @@ [pageSizeOptions]="[100, 200, 500,1000]" (page)="onPageChange($event)"> -
- } - + } +
From 1d809975eae0f78b2b0bae9813356297bdd4396d Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:20:39 +0200 Subject: [PATCH 11/19] Select the highest minor player version automatically --- .../services/workspace-player.service.ts | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-player.service.ts b/apps/backend/src/app/database/services/workspace-player.service.ts index 112a30cdc..8730f6fbc 100644 --- a/apps/backend/src/app/database/services/workspace-player.service.ts +++ b/apps/backend/src/app/database/services/workspace-player.service.ts @@ -34,20 +34,52 @@ export class WorkspacePlayerService { this.logger.log(`Attempting to retrieve files for player '${playerName}' in workspace ${workspaceId}`); try { + // Parse the player name to extract module and major version + const playerNameUpperCase = playerName.toUpperCase(); + const regex = /^(\D+)-(\d+)\.(\d+)$/; + const matches = playerNameUpperCase.match(regex); + + if (matches) { + const module = matches[1]; + const majorVersion = matches[2]; + + // Always search for all players with the same module and major version + const similarPlayers = await this.fileUploadRepository + .createQueryBuilder('file') + .where('file.workspace_id = :workspaceId', { workspaceId }) + .andWhere('file.file_id LIKE :pattern', { pattern: `${module}-${majorVersion}.%` }) + .getMany(); + + if (similarPlayers.length > 0) { + this.logger.log(`Found ${similarPlayers.length} player(s) with module ${module} and major version ${majorVersion} in workspace ${workspaceId}`); + + // Sort by minor version (descending) and return the highest one + similarPlayers.sort((a, b) => { + const minorA = parseInt(a.file_id.split('.')[1], 10); + const minorB = parseInt(b.file_id.split('.')[1], 10); + return minorB - minorA; // Descending order + }); + + this.logger.log(`Automatically selecting player with highest minor version: ${similarPlayers[0].file_id}`); + return [similarPlayers[0]]; + } + } + + // If no players with the same module and major version were found, try to find an exact match const files = await this.fileUploadRepository.find({ where: { - file_id: playerName.toUpperCase(), + file_id: playerNameUpperCase, workspace_id: workspaceId } }); - if (files.length === 0) { - this.logger.warn(`No files found for player '${playerName}' in workspace ${workspaceId}`); - } else { + if (files.length > 0) { this.logger.log(`Found ${files.length} file(s) for player '${playerName}' in workspace ${workspaceId}`); + return files; } - return files; + this.logger.warn(`No files found for player '${playerName}' in workspace ${workspaceId}`); + return []; } catch (error) { this.logger.error( `Failed to retrieve files for player '${playerName}' in workspace ${workspaceId}`, From f197f417bb16f26c12fa509242089e2d4e992ec0 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:12:16 +0200 Subject: [PATCH 12/19] Implement search filtering for test-results in the backend --- .../workspace-test-results.controller.ts | 11 +- .../workspace-test-results.service.ts | 44 ++-- .../src/app/services/backend.service.ts | 10 +- .../test-results/test-results.component.html | 33 ++- .../test-results/test-results.component.scss | 32 ++- .../test-results/test-results.component.ts | 229 +++++++++++++----- 6 files changed, 264 insertions(+), 95 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index e5ff55365..2294ae765 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -42,6 +42,12 @@ export class WorkspaceTestResultsController { description: 'Number of items per page', type: Number }) + @ApiQuery({ + name: 'searchText', + required: false, + description: 'Text to search for in code, group, or login fields', + type: String + }) @ApiOkResponse({ description: 'Test results retrieved successfully.', schema: { @@ -59,9 +65,10 @@ export class WorkspaceTestResultsController { async findTestResults( @Param('workspace_id') workspace_id: number, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @Query('searchText') searchText?: string ): Promise<{ data: Persons[]; total: number; page: number; limit: number }> { - const [data, total] = await this.workspaceTestResultsService.findTestResults(workspace_id, { page, limit }); + const [data, total] = await this.workspaceTestResultsService.findTestResults(workspace_id, { page, limit, searchText }); return { data, total, page, limit }; diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index c63620ce7..4584c22be 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -162,8 +162,8 @@ export class WorkspaceTestResultsService { } } - async findTestResults(workspace_id: number, options: { page: number; limit: number }): Promise<[Persons[], number]> { - const { page, limit } = options; + async findTestResults(workspace_id: number, options: { page: number; limit: number; searchText?: string }): Promise<[Persons[], number]> { + const { page, limit, searchText } = options; if (!workspace_id || workspace_id <= 0) { throw new Error('Invalid workspace_id provided'); @@ -174,19 +174,33 @@ export class WorkspaceTestResultsService { const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT try { - const [results, total] = await this.personsRepository.findAndCount({ - where: { workspace_id: workspace_id }, - select: [ - 'id', - 'group', - 'login', - 'code', - 'uploaded_at' - ], - skip: (validPage - 1) * validLimit, - take: validLimit, - order: { code: 'ASC' } - }); + // Use query builder to support text search + const queryBuilder = this.personsRepository.createQueryBuilder('person') + .where('person.workspace_id = :workspace_id', { workspace_id }) + .select([ + 'person.id', + 'person.group', + 'person.login', + 'person.code', + 'person.uploaded_at' + ]); + + // Add search condition if searchText is provided + if (searchText && searchText.trim() !== '') { + queryBuilder.andWhere( + '(person.code ILIKE :searchText OR person.group ILIKE :searchText OR person.login ILIKE :searchText)', + { searchText: `%${searchText.trim()}%` } + ); + } + + // Add pagination + queryBuilder + .skip((validPage - 1) * validLimit) + .take(validLimit) + .orderBy('person.code', 'ASC'); + + // Execute query + const [results, total] = await queryBuilder.getManyAndCount(); return [results, total]; } catch (error) { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index e2f82915c..184933981 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -538,11 +538,17 @@ export class BackendService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTestResults(workspaceId: number, page: number, limit: number): Observable { - const params = { + getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { + const params: { [key: string]: string } = { page: page.toString(), limit: limit.toString() }; + + // Add searchText parameter if provided + if (searchText && searchText.trim() !== '') { + params.searchText = searchText.trim(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.http.get( `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 70da6193c..c5eba6ec8 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -43,27 +43,36 @@ } @if(!isUploadingResults){ - @if ( dataSource && dataSource.data.length) { + @if ( dataSource) {

Testpersonen

Wählen Sie eine Testperson aus, um deren Ergebnisse anzuzeigen

- @if (isLoading) { + @if (isLoading && !isSearching) {

Daten werden geladen...

} - @if (!isLoading) { + @if (!isLoading || isSearching) {
search +
+ @if (isSearching) { +
+ + Suche läuft... +
+ } @@ -112,6 +121,20 @@

Testpersonen

*matRowDef="let row; columns: displayedColumns;" class="clickable-row">
+ + @if (dataSource && dataSource.data.length === 0) { +
+ search_off +

Keine Ergebnisse gefunden

+

Bitte versuchen Sie es mit einem anderen Suchbegriff oder setzen Sie den Filter zurück.

+ +
+ }
Antworten

}
Value: - {{ response.value | slice:0:1000 }}{{ response.value?.length > 1000 ? '...' : '' }} + {{ response.value | slice:0:1000 }}{{ response.value.length > 1000 ? '...' : '' }}
} @@ -271,7 +294,7 @@

Antworten

} - @if (logs?.length > 0) { + @if (logs.length > 0) {

Logs

Protokolleinträge für die ausgewählte Unit

diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss index 3977d2455..dafa77fb7 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss @@ -158,11 +158,12 @@ align-items: center; background-color: #f5f9ff; border-radius: 28px; - padding: 10px 18px; + padding: 6px 16px; margin-bottom: 20px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); border: 1px solid rgba(25, 118, 210, 0.1); transition: all 0.2s ease; + width: 100%; &:focus-within { box-shadow: 0 3px 8px rgba(25, 118, 210, 0.15); @@ -171,18 +172,19 @@ .search-icon { color: #1976d2; - margin-right: 10px; + margin-right: 8px; opacity: 0.8; + font-size: 18px; } .search-input { border: none; background: transparent; flex: 1; - font-size: 15px; + font-size: 14px; outline: none; color: #333; - padding: 4px 0; + padding: 2px 0; &::placeholder { color: #7a9cc6; @@ -202,6 +204,28 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); background-color: white; + .search-loading-indicator { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + padding: 8px 16px; + background-color: rgba(25, 118, 210, 0.1); + border-radius: 0 8px 0 8px; + z-index: 10; + + mat-spinner { + margin-right: 8px; + } + + span { + font-size: 14px; + color: #1976d2; + font-weight: 500; + } + } + /* Custom scrollbar styling */ &::-webkit-scrollbar { width: 8px; diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index d2324cb6b..c883db1e5 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -7,12 +7,18 @@ import { MatTableDataSource, MatCell, MatColumnDef, MatHeaderCell, MatHeaderRow, MatRow } from '@angular/material/table'; import { - Component, OnInit, ViewChild, inject + Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { FormsModule, UntypedFormGroup } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { + Subject, + Subscription, + debounceTime, + distinctUntilChanged +} from 'rxjs'; import { SelectionModel } from '@angular/cdk/collections'; import { MatAccordion, @@ -33,7 +39,6 @@ import { MatDivider } from '@angular/material/divider'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BackendService } from '../../../services/backend.service'; import { AppService } from '../../../services/app.service'; -import { TestGroupsInListDto } from '../../../../../../../api-dto/test-groups/testgroups-in-list.dto'; import { TestCenterImportComponent } from '../test-center-import/test-center-import.component'; import { LogDialogComponent } from '../booklet-log-dialog/log-dialog.component'; import { TagDialogComponent } from '../tag-dialog/tag-dialog.component'; @@ -43,6 +48,75 @@ import { CreateUnitTagDto } from '../../../../../../../api-dto/unit-tags/create- import { UpdateUnitTagDto } from '../../../../../../../api-dto/unit-tags/update-unit-tag.dto'; import { UnitNoteDto } from '../../../../../../../api-dto/unit-notes/unit-note.dto'; +interface BookletLog { + id: number; + bookletid: number; + ts: string; + parameter: string; + key: string; +} + +interface BookletSession { + id: number; + browser: string; + os: string; + screen: string; + ts: string; +} + +interface UnitResult { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code?: number; + score?: number; + codedstatus?: string; +} + +interface UnitLog { + id: number; + unitid: number; + ts: string; + key: string; + parameter: string; +} + +interface Unit { + id: number; + bookletid: number; + name: string; + alias: string | null; + results: UnitResult[]; + logs: UnitLog[]; +} + +interface Booklet { + id: number; + personid: number; + name: string; + title?: string; + size: number; + logs: BookletLog[]; + sessions?: BookletSession[]; + units: Unit[]; +} + +interface Response { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code?: number; + score?: number; + codedstatus?: string; + expanded?: boolean; +} + interface P { id: number; code: string; @@ -90,7 +164,7 @@ interface P { MatDivider, MatTooltipModule] }) -export class TestResultsComponent implements OnInit { +export class TestResultsComponent implements OnInit, OnDestroy { private dialog = inject(MatDialog); private backendService = inject(BackendService); private appService = inject(AppService); @@ -98,40 +172,56 @@ export class TestResultsComponent implements OnInit { private snackBar = inject(MatSnackBar); private translateService = inject(TranslateService); + // Search debounce + private searchSubject = new Subject(); + private searchSubscription: Subscription | null = null; + private readonly SEARCH_DEBOUNCE_TIME = 800; // milliseconds + selection = new SelectionModel

(true, []); - tableSelectionCheckboxes = new SelectionModel(true, []); dataSource !: MatTableDataSource

; displayedColumns: string[] = ['select', 'code', 'group', 'login', 'uploaded_at']; data: P[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - booklets: { id: number; title: string, name:string, units:any, logs?: any[], sessions?: any[] }[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results: { [key: string]: any }[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - responses: any = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logs: any = []; + booklets: Booklet[] = []; + results: { [key: string]: unknown }[] = []; + responses: Response[] = []; + logs: UnitLog[] = []; bookletLogs: { [key: string]: unknown }[] = []; totalRecords: number = 0; pageSize: number = 50; pageIndex: number = 0; - selectedUnit: { alias: string; [key: string]: unknown } | undefined; + selectedUnit: Unit | undefined; testPerson!: P; - selectedBooklet: any; + selectedBooklet!: Booklet | string; isLoading: boolean = true; isUploadingResults: boolean = false; + isSearching: boolean = false; unitTags: UnitTagDto[] = []; newTagText: string = ''; unitTagsMap: Map = new Map(); unitNotes: UnitNoteDto[] = []; unitNotesMap: Map = new Map(); - readonly SHORT_PROCESSING_TIME_THRESHOLD_MS: number = 60000; // 1 minute in milliseconds + readonly SHORT_PROCESSING_TIME_THRESHOLD_MS: number = 60000; @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; ngOnInit(): void { - this.createTestResultsList(); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.createTestResultsList(0, this.pageSize, searchText); + }); + + this.createTestResultsList(0, this.pageSize); + } + + ngOnDestroy(): void { + // Clean up subscriptions + if (this.searchSubscription) { + this.searchSubscription.unsubscribe(); + this.searchSubscription = null; + } } onRowClick(row: P): void { @@ -233,15 +323,13 @@ export class TestResultsComponent implements OnInit { applyFilter(event: Event): void { const filterValue = (event.target as HTMLInputElement).value; - this.dataSource.filter = filterValue.trim().toLowerCase(); - - if (this.dataSource.paginator) { - this.dataSource.paginator.firstPage(); - } + // Set isSearching to true when a search is triggered + this.isSearching = true; + // Push the search text to the subject, which will debounce and then trigger the search + this.searchSubject.next(filterValue); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - openBookletLogsDialog(booklet: any) { + openBookletLogsDialog(booklet: Booklet) { this.dialog.open(LogDialogComponent, { width: '700px', data: { @@ -325,16 +413,13 @@ export class TestResultsComponent implements OnInit { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onUnitClick(unit: any): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.responses = unit.results.map((response: any) => ({ + onUnitClick(unit: Unit): void { + this.responses = unit.results.map((response: UnitResult) => ({ ...response, expanded: false })); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.responses.sort((a: any, b: any) => { + this.responses.sort((a: Response, b: Response) => { // First prioritize VALUE_CHANGED status if (a.status === 'VALUE_CHANGED' && b.status !== 'VALUE_CHANGED') { return -1; @@ -583,15 +668,7 @@ export class TestResultsComponent implements OnInit { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onBookletClick(booklet: any): void { - this.bookletLogs = booklet.logs; - // this.logs = this.createUnitHistory(unit); - this.selectedUnit = booklet; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setSelectedBooklet(booklet:any) { + setSelectedBooklet(booklet: Booklet) { this.selectedBooklet = booklet; } @@ -605,13 +682,13 @@ export class TestResultsComponent implements OnInit { * @param booklet The booklet to calculate processing time for * @returns The processing time in milliseconds, or null if it cannot be calculated */ - calculateBookletProcessingTime(booklet: any): number | null { + calculateBookletProcessingTime(booklet: Booklet): number | null { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return null; } - const pollingLog = booklet.logs.find((log: any) => log.key === 'CONTROLLER' && log.parameter === 'RUNNING'); - const terminatedLog = booklet.logs.find((log: any) => log.key === 'CONTROLLER' && log.parameter === 'TERMINATED'); + const pollingLog = booklet.logs.find((log: BookletLog) => log.key === 'CONTROLLER' && log.parameter === 'RUNNING'); + const terminatedLog = booklet.logs.find((log: BookletLog) => log.key === 'CONTROLLER' && log.parameter === 'TERMINATED'); if (pollingLog && terminatedLog) { const pollingTime = Number(pollingLog.ts); const terminatedTime = Number(terminatedLog.ts); @@ -643,7 +720,7 @@ export class TestResultsComponent implements OnInit { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } - isBookletComplete(booklet: any): boolean { + isBookletComplete(booklet: Booklet): boolean { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return true; } @@ -651,19 +728,19 @@ export class TestResultsComponent implements OnInit { if (!booklet.units || !Array.isArray(booklet.units) || booklet.units.length === 0) { return false; } - const unitIdLogs = booklet.logs.filter((log: any) => log.key === 'CURRENT_UNIT_ID'); + const unitIdLogs = booklet.logs.filter((log: BookletLog) => log.key === 'CURRENT_UNIT_ID'); const unitAliases = booklet.units - .map((unit: any) => unit.alias) + .map((unit: Unit) => unit.alias) .filter((alias: string | null) => alias !== null) as string[]; const allUnitsVisited = unitAliases.every( - (alias: string) => unitIdLogs.some((log: any) => log.parameter === alias) + (alias: string) => unitIdLogs.some((log: BookletLog) => log.parameter === alias) ); return allUnitsVisited && unitAliases.length > 0; } - hasShortProcessingTime(booklet: any): boolean { + hasShortProcessingTime(booklet: Booklet): boolean { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return false; } @@ -673,14 +750,12 @@ export class TestResultsComponent implements OnInit { } // Check if any response value for a unit starts with "UEsD" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hasGeogebraResponse(unit: any): boolean { + hasGeogebraResponse(unit: Unit): boolean { if (!unit || !unit.results || !Array.isArray(unit.results)) { return false; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return unit.results.some((response:any) => response.value && typeof response.value === 'string' && response.value.startsWith('UEsD')); + return unit.results.some((response: UnitResult) => response.value && response.value.startsWith('UEsD')); } getColor(status: string): string { @@ -698,17 +773,39 @@ export class TestResultsComponent implements OnInit { } } + /** + * Gets the current search text from the search input field + * @returns The current search text, or an empty string if not available + */ + getCurrentSearchText(): string { + const searchInput = document.querySelector('.search-input') as HTMLInputElement; + return searchInput ? searchInput.value : ''; + } + + /** + * Clears the search input and resets the search results + */ + clearSearch(): void { + const searchInput = document.querySelector('.search-input') as HTMLInputElement; + if (searchInput) { + searchInput.value = ''; + this.createTestResultsList(0, this.pageSize); + } + } + onPaginatorChange(event: PageEvent): void { this.pageSize = event.pageSize; this.pageIndex = event.pageIndex; - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } - createTestResultsList(page: number = 0, limit: number = 50): void { + createTestResultsList(page: number = 0, limit: number = 50, searchText: string = ''): void { const validPage = Math.max(0, page); - this.backendService.getTestResults(this.appService.selectedWorkspaceId, validPage, limit) + this.isLoading = !this.isSearching; + this.backendService.getTestResults(this.appService.selectedWorkspaceId, validPage, limit, searchText) .subscribe(response => { this.isLoading = false; + this.isSearching = false; const { data, total } = response; this.updateTable(data, total); }); @@ -732,16 +829,14 @@ export class TestResultsComponent implements OnInit { this.selection.toggle(row); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private updateTable(data: any[], total: number): void { - this.data = data; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mappedResults = data.map((result: any) => ({ - id: result.id, - code: result.code, - group: result.group, - login: result.login, - uploaded_at: result.uploaded_at + private updateTable(data: Record[], total: number): void { + this.data = data as any; + const mappedResults = data.map((result: Record) => ({ + id: result.id as number, + code: result.code as string, + group: result.group as string, + login: result.login as string, + uploaded_at: result.uploaded_at as Date })); this.dataSource = new MatTableDataSource(mappedResults); this.totalRecords = total; @@ -759,7 +854,7 @@ export class TestResultsComponent implements OnInit { dialogRef.afterClosed().subscribe((result: boolean | UntypedFormGroup) => { if (result instanceof UntypedFormGroup || result) { - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } }); } @@ -776,7 +871,7 @@ export class TestResultsComponent implements OnInit { resultType ).subscribe(() => { setTimeout(() => { - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); }, 1000); this.isLoading = false; this.isUploadingResults = false; @@ -798,7 +893,7 @@ export class TestResultsComponent implements OnInit { '', { duration: 1000 } ); - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } else { this.snackBar.open( this.translateService.instant('ws-admin.test-group-not-deleted'), @@ -824,7 +919,7 @@ export class TestResultsComponent implements OnInit { '', { duration: 1000 } ); - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } else { this.snackBar.open( this.translateService.instant('ws-admin.test-group-not-coded'), From ccd29b1278c7fea7be1460f33923e83d9157f0f7 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:12:16 +0200 Subject: [PATCH 13/19] Implement search filtering for test-results in the backend --- .../workspace-test-results.controller.ts | 11 +- .../workspace-test-results.service.ts | 44 ++- .../src/app/services/backend.service.ts | 10 +- .../test-results/test-results.component.html | 35 ++- .../test-results/test-results.component.scss | 32 +- .../test-results/test-results.component.ts | 276 ++++++++++-------- 6 files changed, 263 insertions(+), 145 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index e5ff55365..2294ae765 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -42,6 +42,12 @@ export class WorkspaceTestResultsController { description: 'Number of items per page', type: Number }) + @ApiQuery({ + name: 'searchText', + required: false, + description: 'Text to search for in code, group, or login fields', + type: String + }) @ApiOkResponse({ description: 'Test results retrieved successfully.', schema: { @@ -59,9 +65,10 @@ export class WorkspaceTestResultsController { async findTestResults( @Param('workspace_id') workspace_id: number, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @Query('searchText') searchText?: string ): Promise<{ data: Persons[]; total: number; page: number; limit: number }> { - const [data, total] = await this.workspaceTestResultsService.findTestResults(workspace_id, { page, limit }); + const [data, total] = await this.workspaceTestResultsService.findTestResults(workspace_id, { page, limit, searchText }); return { data, total, page, limit }; diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index c63620ce7..4584c22be 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -162,8 +162,8 @@ export class WorkspaceTestResultsService { } } - async findTestResults(workspace_id: number, options: { page: number; limit: number }): Promise<[Persons[], number]> { - const { page, limit } = options; + async findTestResults(workspace_id: number, options: { page: number; limit: number; searchText?: string }): Promise<[Persons[], number]> { + const { page, limit, searchText } = options; if (!workspace_id || workspace_id <= 0) { throw new Error('Invalid workspace_id provided'); @@ -174,19 +174,33 @@ export class WorkspaceTestResultsService { const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT try { - const [results, total] = await this.personsRepository.findAndCount({ - where: { workspace_id: workspace_id }, - select: [ - 'id', - 'group', - 'login', - 'code', - 'uploaded_at' - ], - skip: (validPage - 1) * validLimit, - take: validLimit, - order: { code: 'ASC' } - }); + // Use query builder to support text search + const queryBuilder = this.personsRepository.createQueryBuilder('person') + .where('person.workspace_id = :workspace_id', { workspace_id }) + .select([ + 'person.id', + 'person.group', + 'person.login', + 'person.code', + 'person.uploaded_at' + ]); + + // Add search condition if searchText is provided + if (searchText && searchText.trim() !== '') { + queryBuilder.andWhere( + '(person.code ILIKE :searchText OR person.group ILIKE :searchText OR person.login ILIKE :searchText)', + { searchText: `%${searchText.trim()}%` } + ); + } + + // Add pagination + queryBuilder + .skip((validPage - 1) * validLimit) + .take(validLimit) + .orderBy('person.code', 'ASC'); + + // Execute query + const [results, total] = await queryBuilder.getManyAndCount(); return [results, total]; } catch (error) { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index e2f82915c..184933981 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -538,11 +538,17 @@ export class BackendService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTestResults(workspaceId: number, page: number, limit: number): Observable { - const params = { + getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { + const params: { [key: string]: string } = { page: page.toString(), limit: limit.toString() }; + + // Add searchText parameter if provided + if (searchText && searchText.trim() !== '') { + params.searchText = searchText.trim(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.http.get( `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 70da6193c..68c1adedd 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -43,27 +43,36 @@ } @if(!isUploadingResults){ - @if ( dataSource && dataSource.data.length) { + @if ( dataSource) {

Testpersonen

Wählen Sie eine Testperson aus, um deren Ergebnisse anzuzeigen

- @if (isLoading) { + @if (isLoading && !isSearching) {

Daten werden geladen...

} - @if (!isLoading) { + @if (!isLoading || isSearching) {
search +
+ @if (isSearching) { +
+ + Suche läuft... +
+ } @@ -112,6 +121,20 @@

Testpersonen

*matRowDef="let row; columns: displayedColumns;" class="clickable-row">
+ + @if (dataSource && dataSource.data.length === 0) { +
+ search_off +

Keine Ergebnisse gefunden

+

Bitte versuchen Sie es mit einem anderen Suchbegriff oder setzen Sie den Filter zurück.

+ +
+ }
Aufgaben @for (unit of booklet.units; track unit) { assignment {{ unit?.alias || 'Unbenannte Einheit' }} @@ -261,7 +284,7 @@

Antworten

}
Value: - {{ response.value | slice:0:1000 }}{{ response.value?.length > 1000 ? '...' : '' }} + {{ response.value | slice:0:1000 }}{{ response.value.length > 1000 ? '...' : '' }}
} @@ -271,7 +294,7 @@

Antworten

} - @if (logs?.length > 0) { + @if (logs.length > 0) {

Logs

Protokolleinträge für die ausgewählte Unit

diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss index 3977d2455..dafa77fb7 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.scss @@ -158,11 +158,12 @@ align-items: center; background-color: #f5f9ff; border-radius: 28px; - padding: 10px 18px; + padding: 6px 16px; margin-bottom: 20px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); border: 1px solid rgba(25, 118, 210, 0.1); transition: all 0.2s ease; + width: 100%; &:focus-within { box-shadow: 0 3px 8px rgba(25, 118, 210, 0.15); @@ -171,18 +172,19 @@ .search-icon { color: #1976d2; - margin-right: 10px; + margin-right: 8px; opacity: 0.8; + font-size: 18px; } .search-input { border: none; background: transparent; flex: 1; - font-size: 15px; + font-size: 14px; outline: none; color: #333; - padding: 4px 0; + padding: 2px 0; &::placeholder { color: #7a9cc6; @@ -202,6 +204,28 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); background-color: white; + .search-loading-indicator { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + padding: 8px 16px; + background-color: rgba(25, 118, 210, 0.1); + border-radius: 0 8px 0 8px; + z-index: 10; + + mat-spinner { + margin-right: 8px; + } + + span { + font-size: 14px; + color: #1976d2; + font-weight: 500; + } + } + /* Custom scrollbar styling */ &::-webkit-scrollbar { width: 8px; diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index d2324cb6b..05dfdc5ce 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -7,12 +7,18 @@ import { MatTableDataSource, MatCell, MatColumnDef, MatHeaderCell, MatHeaderRow, MatRow } from '@angular/material/table'; import { - Component, OnInit, ViewChild, inject + Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { FormsModule, UntypedFormGroup } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { + Subject, + Subscription, + debounceTime, + distinctUntilChanged +} from 'rxjs'; import { SelectionModel } from '@angular/cdk/collections'; import { MatAccordion, @@ -33,7 +39,6 @@ import { MatDivider } from '@angular/material/divider'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BackendService } from '../../../services/backend.service'; import { AppService } from '../../../services/app.service'; -import { TestGroupsInListDto } from '../../../../../../../api-dto/test-groups/testgroups-in-list.dto'; import { TestCenterImportComponent } from '../test-center-import/test-center-import.component'; import { LogDialogComponent } from '../booklet-log-dialog/log-dialog.component'; import { TagDialogComponent } from '../tag-dialog/tag-dialog.component'; @@ -43,6 +48,75 @@ import { CreateUnitTagDto } from '../../../../../../../api-dto/unit-tags/create- import { UpdateUnitTagDto } from '../../../../../../../api-dto/unit-tags/update-unit-tag.dto'; import { UnitNoteDto } from '../../../../../../../api-dto/unit-notes/unit-note.dto'; +interface BookletLog { + id: number; + bookletid: number; + ts: string; + parameter: string; + key: string; +} + +interface BookletSession { + id: number; + browser: string; + os: string; + screen: string; + ts: string; +} + +interface UnitResult { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code?: number; + score?: number; + codedstatus?: string; +} + +interface UnitLog { + id: number; + unitid: number; + ts: string; + key: string; + parameter: string; +} + +interface Unit { + id: number; + bookletid: number; + name: string; + alias: string | null; + results: UnitResult[]; + logs: UnitLog[]; +} + +interface Booklet { + id: number; + personid: number; + name: string; + title?: string; + size: number; + logs: BookletLog[]; + sessions?: BookletSession[]; + units: Unit[]; +} + +interface Response { + id: number; + unitid: number; + variableid: string; + status: string; + value: string; + subform: string; + code?: number; + score?: number; + codedstatus?: string; + expanded?: boolean; +} + interface P { id: number; code: string; @@ -90,7 +164,7 @@ interface P { MatDivider, MatTooltipModule] }) -export class TestResultsComponent implements OnInit { +export class TestResultsComponent implements OnInit, OnDestroy { private dialog = inject(MatDialog); private backendService = inject(BackendService); private appService = inject(AppService); @@ -98,40 +172,56 @@ export class TestResultsComponent implements OnInit { private snackBar = inject(MatSnackBar); private translateService = inject(TranslateService); + // Search debounce + private searchSubject = new Subject(); + private searchSubscription: Subscription | null = null; + private readonly SEARCH_DEBOUNCE_TIME = 800; // milliseconds + selection = new SelectionModel

(true, []); - tableSelectionCheckboxes = new SelectionModel(true, []); dataSource !: MatTableDataSource

; displayedColumns: string[] = ['select', 'code', 'group', 'login', 'uploaded_at']; data: P[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - booklets: { id: number; title: string, name:string, units:any, logs?: any[], sessions?: any[] }[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results: { [key: string]: any }[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - responses: any = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logs: any = []; + booklets: Booklet[] = []; + results: { [key: string]: unknown }[] = []; + responses: Response[] = []; + logs: UnitLog[] = []; bookletLogs: { [key: string]: unknown }[] = []; totalRecords: number = 0; pageSize: number = 50; pageIndex: number = 0; - selectedUnit: { alias: string; [key: string]: unknown } | undefined; + selectedUnit: Unit | undefined; testPerson!: P; - selectedBooklet: any; + selectedBooklet!: Booklet | string; isLoading: boolean = true; isUploadingResults: boolean = false; + isSearching: boolean = false; unitTags: UnitTagDto[] = []; newTagText: string = ''; unitTagsMap: Map = new Map(); unitNotes: UnitNoteDto[] = []; unitNotesMap: Map = new Map(); - readonly SHORT_PROCESSING_TIME_THRESHOLD_MS: number = 60000; // 1 minute in milliseconds + readonly SHORT_PROCESSING_TIME_THRESHOLD_MS: number = 60000; @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; ngOnInit(): void { - this.createTestResultsList(); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.createTestResultsList(0, this.pageSize, searchText); + }); + + this.createTestResultsList(0, this.pageSize); + } + + ngOnDestroy(): void { + // Clean up subscriptions + if (this.searchSubscription) { + this.searchSubscription.unsubscribe(); + this.searchSubscription = null; + } } onRowClick(row: P): void { @@ -224,7 +314,7 @@ export class TestResultsComponent implements OnInit { const url = this.router .serializeUrl( this.router.createUrlTree( - [`replay/${this.testPerson.login}@${this.testPerson.code}@${this.selectedBooklet}/${this.selectedUnit?.alias}/0/0`], + [`replay/${this.testPerson.login}@${this.testPerson.code}@${this.testPerson.group}/${this.selectedUnit?.alias}/0/0`], { queryParams: queryParams }) ); window.open(`#/${url}`, '_blank'); @@ -233,15 +323,11 @@ export class TestResultsComponent implements OnInit { applyFilter(event: Event): void { const filterValue = (event.target as HTMLInputElement).value; - this.dataSource.filter = filterValue.trim().toLowerCase(); - - if (this.dataSource.paginator) { - this.dataSource.paginator.firstPage(); - } + this.isSearching = true; + this.searchSubject.next(filterValue); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - openBookletLogsDialog(booklet: any) { + openBookletLogsDialog(booklet: Booklet) { this.dialog.open(LogDialogComponent, { width: '700px', data: { @@ -325,16 +411,14 @@ export class TestResultsComponent implements OnInit { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onUnitClick(unit: any): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.responses = unit.results.map((response: any) => ({ + onUnitClick(unit: Unit, booklet: Booklet): void { + this.responses = unit.results.map((response: UnitResult) => ({ ...response, expanded: false })); + this.selectedBooklet = booklet.name; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.responses.sort((a: any, b: any) => { + this.responses.sort((a: Response, b: Response) => { // First prioritize VALUE_CHANGED status if (a.status === 'VALUE_CHANGED' && b.status !== 'VALUE_CHANGED') { return -1; @@ -354,9 +438,7 @@ export class TestResultsComponent implements OnInit { // this.loadUnitNotes(); } - /** - * Load tags for the selected unit - */ + loadUnitTags(): void { if (this.selectedUnit && this.selectedUnit.id) { this.backendService.getUnitTags( @@ -383,9 +465,7 @@ export class TestResultsComponent implements OnInit { } } - /** - * Load notes for the selected unit - */ + loadUnitNotes(): void { if (this.selectedUnit && this.selectedUnit.id) { this.backendService.getUnitNotes( @@ -412,9 +492,6 @@ export class TestResultsComponent implements OnInit { } } - /** - * Add a new tag to the selected unit - */ addUnitTag(): void { if (!this.newTagText.trim()) { this.snackBar.open( @@ -431,11 +508,6 @@ export class TestResultsComponent implements OnInit { } } - /** - * Add a new tag to a specific unit - * @param unitId The ID of the unit - * @param tagText The text for the new tag - */ addTagToUnit(unitId: number, tagText: string): void { if (!tagText.trim()) { this.snackBar.open( @@ -482,11 +554,6 @@ export class TestResultsComponent implements OnInit { }); } - /** - * Update an existing tag - * @param tagId The ID of the tag to update - * @param newText The new text for the tag - */ updateUnitTag(tagId: number, newText: string): void { if (!newText.trim()) { this.snackBar.open( @@ -529,21 +596,13 @@ export class TestResultsComponent implements OnInit { }); } - /** - * Delete a tag from the selected unit - * @param tagId The ID of the tag to delete - */ + deleteUnitTag(tagId: number): void { if (this.selectedUnit && this.selectedUnit.id) { this.deleteTagFromUnit(tagId, this.selectedUnit.id as number); } } - /** - * Delete a tag from a specific unit - * @param tagId The ID of the tag to delete - * @param unitId The ID of the unit the tag belongs to - */ deleteTagFromUnit(tagId: number, unitId: number): void { this.backendService.deleteUnitTag( this.appService.selectedWorkspaceId, @@ -551,12 +610,10 @@ export class TestResultsComponent implements OnInit { ).subscribe({ next: success => { if (success) { - // If this is the selected unit, update the unitTags array if (this.selectedUnit && this.selectedUnit.id === unitId) { this.unitTags = this.unitTags.filter(tag => tag.id !== tagId); } - // Update the unitTagsMap const tags = this.unitTagsMap.get(unitId) || []; this.unitTagsMap.set(unitId, tags.filter(tag => tag.id !== tagId)); @@ -583,16 +640,8 @@ export class TestResultsComponent implements OnInit { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onBookletClick(booklet: any): void { - this.bookletLogs = booklet.logs; - // this.logs = this.createUnitHistory(unit); - this.selectedUnit = booklet; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setSelectedBooklet(booklet:any) { - this.selectedBooklet = booklet; + setSelectedBooklet(booklet: Booklet) { + this.selectedBooklet = booklet.name; } formatTimestamp(timestamp: string): string { @@ -600,18 +649,13 @@ export class TestResultsComponent implements OnInit { return date.toLocaleString(); } - /** - * Calculates the processing time for a booklet based on its logs - * @param booklet The booklet to calculate processing time for - * @returns The processing time in milliseconds, or null if it cannot be calculated - */ - calculateBookletProcessingTime(booklet: any): number | null { + calculateBookletProcessingTime(booklet: Booklet): number | null { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return null; } - const pollingLog = booklet.logs.find((log: any) => log.key === 'CONTROLLER' && log.parameter === 'RUNNING'); - const terminatedLog = booklet.logs.find((log: any) => log.key === 'CONTROLLER' && log.parameter === 'TERMINATED'); + const pollingLog = booklet.logs.find((log: BookletLog) => log.key === 'CONTROLLER' && log.parameter === 'RUNNING'); + const terminatedLog = booklet.logs.find((log: BookletLog) => log.key === 'CONTROLLER' && log.parameter === 'TERMINATED'); if (pollingLog && terminatedLog) { const pollingTime = Number(pollingLog.ts); const terminatedTime = Number(terminatedLog.ts); @@ -624,26 +668,16 @@ export class TestResultsComponent implements OnInit { return null; } - /** - * Formats a duration in milliseconds to a readable format (minutes:seconds) - * @param durationMs The duration in milliseconds - * @returns A formatted string in the format MM:SS - */ + formatDuration(durationMs: number | null): string { if (durationMs === null || durationMs < 0) return '00:00'; - - // Convert to seconds const totalSeconds = Math.floor(durationMs / 1000); - - // Calculate minutes and remaining seconds const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - - // Format as MM:SS return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } - isBookletComplete(booklet: any): boolean { + isBookletComplete(booklet: Booklet): boolean { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return true; } @@ -651,19 +685,19 @@ export class TestResultsComponent implements OnInit { if (!booklet.units || !Array.isArray(booklet.units) || booklet.units.length === 0) { return false; } - const unitIdLogs = booklet.logs.filter((log: any) => log.key === 'CURRENT_UNIT_ID'); + const unitIdLogs = booklet.logs.filter((log: BookletLog) => log.key === 'CURRENT_UNIT_ID'); const unitAliases = booklet.units - .map((unit: any) => unit.alias) + .map((unit: Unit) => unit.alias) .filter((alias: string | null) => alias !== null) as string[]; const allUnitsVisited = unitAliases.every( - (alias: string) => unitIdLogs.some((log: any) => log.parameter === alias) + (alias: string) => unitIdLogs.some((log: BookletLog) => log.parameter === alias) ); return allUnitsVisited && unitAliases.length > 0; } - hasShortProcessingTime(booklet: any): boolean { + hasShortProcessingTime(booklet: Booklet): boolean { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return false; } @@ -672,15 +706,12 @@ export class TestResultsComponent implements OnInit { return processingTime === null || processingTime < this.SHORT_PROCESSING_TIME_THRESHOLD_MS; } - // Check if any response value for a unit starts with "UEsD" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hasGeogebraResponse(unit: any): boolean { + hasGeogebraResponse(unit: Unit): boolean { if (!unit || !unit.results || !Array.isArray(unit.results)) { return false; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return unit.results.some((response:any) => response.value && typeof response.value === 'string' && response.value.startsWith('UEsD')); + return unit.results.some((response: UnitResult) => response.value && response.value.startsWith('UEsD')); } getColor(status: string): string { @@ -698,17 +729,32 @@ export class TestResultsComponent implements OnInit { } } + getCurrentSearchText(): string { + const searchInput = document.querySelector('.search-input') as HTMLInputElement; + return searchInput ? searchInput.value : ''; + } + + clearSearch(): void { + const searchInput = document.querySelector('.search-input') as HTMLInputElement; + if (searchInput) { + searchInput.value = ''; + this.createTestResultsList(0, this.pageSize); + } + } + onPaginatorChange(event: PageEvent): void { this.pageSize = event.pageSize; this.pageIndex = event.pageIndex; - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } - createTestResultsList(page: number = 0, limit: number = 50): void { + createTestResultsList(page: number = 0, limit: number = 50, searchText: string = ''): void { const validPage = Math.max(0, page); - this.backendService.getTestResults(this.appService.selectedWorkspaceId, validPage, limit) + this.isLoading = !this.isSearching; + this.backendService.getTestResults(this.appService.selectedWorkspaceId, validPage, limit, searchText) .subscribe(response => { this.isLoading = false; + this.isSearching = false; const { data, total } = response; this.updateTable(data, total); }); @@ -732,16 +778,14 @@ export class TestResultsComponent implements OnInit { this.selection.toggle(row); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private updateTable(data: any[], total: number): void { - this.data = data; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mappedResults = data.map((result: any) => ({ - id: result.id, - code: result.code, - group: result.group, - login: result.login, - uploaded_at: result.uploaded_at + private updateTable(data: Record[], total: number): void { + this.data = data as any; + const mappedResults = data.map((result: Record) => ({ + id: result.id as number, + code: result.code as string, + group: result.group as string, + login: result.login as string, + uploaded_at: result.uploaded_at as Date })); this.dataSource = new MatTableDataSource(mappedResults); this.totalRecords = total; @@ -759,7 +803,7 @@ export class TestResultsComponent implements OnInit { dialogRef.afterClosed().subscribe((result: boolean | UntypedFormGroup) => { if (result instanceof UntypedFormGroup || result) { - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } }); } @@ -776,7 +820,7 @@ export class TestResultsComponent implements OnInit { resultType ).subscribe(() => { setTimeout(() => { - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); }, 1000); this.isLoading = false; this.isUploadingResults = false; @@ -798,7 +842,7 @@ export class TestResultsComponent implements OnInit { '', { duration: 1000 } ); - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } else { this.snackBar.open( this.translateService.instant('ws-admin.test-group-not-deleted'), @@ -824,7 +868,7 @@ export class TestResultsComponent implements OnInit { '', { duration: 1000 } ); - this.createTestResultsList(this.pageIndex, this.pageSize); + this.createTestResultsList(this.pageIndex, this.pageSize, this.getCurrentSearchText()); } else { this.snackBar.open( this.translateService.instant('ws-admin.test-group-not-coded'), From 4771ac888fced201b43f0494c93c522d8bf74efc Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:02:30 +0200 Subject: [PATCH 14/19] Add unit tags to booklet response --- .../workspace-test-results.service.ts | 17 +++++- .../src/app/services/backend.service.ts | 20 +------ .../test-results/test-results.component.ts | 55 +++---------------- 3 files changed, 27 insertions(+), 65 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 4584c22be..59090f575 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -9,6 +9,7 @@ import { BookletInfo } from '../entities/bookletInfo.entity'; import { BookletLog } from '../entities/bookletLog.entity'; import { UnitLog } from '../entities/unitLog.entity'; import { Session } from '../entities/session.entity'; +import { UnitTagService } from './unit-tag.service'; @Injectable() export class WorkspaceTestResultsService { @@ -31,7 +32,8 @@ export class WorkspaceTestResultsService { private unitLogRepository: Repository, @InjectRepository(Session) private sessionRepository: Repository, - private readonly connection: Connection + private readonly connection: Connection, + private readonly unitTagService: UnitTagService ) {} async findPersonTestResults(personId: number, workspaceId: number): Promise<{ @@ -48,6 +50,7 @@ export class WorkspaceTestResultsService { alias: string | null; results: { id: number; unitid: number }[]; logs: { id: number; unitid: number; ts: string; key: string; parameter: string }[]; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; }[]; }[]> { if (!personId || !workspaceId) { @@ -114,6 +117,15 @@ export class WorkspaceTestResultsService { select: ['id', 'unitid', 'ts', 'key', 'parameter'] }); + const allUnitTags = await Promise.all( + unitIds.map(unitId => this.unitTagService.findAllByUnitId(unitId)) + ); + + const unitTagsMap = new Map(); + unitIds.forEach((unitId, index) => { + unitTagsMap.set(unitId, allUnitTags[index]); + }); + return booklets.map(booklet => { const bookletInfo = bookletInfoData.find(info => info.id === booklet.infoid); return { @@ -149,7 +161,8 @@ export class WorkspaceTestResultsService { ts: log.ts.toString(), key: log.key, parameter: log.parameter - })) + })), + tags: unitTagsMap.get(unit.id) || [] })) }; }); diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 184933981..4cd5da26a 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -544,7 +544,6 @@ export class BackendService { limit: limit.toString() }; - // Add searchText parameter if provided if (searchText && searchText.trim() !== '') { params.searchText = searchText.trim(); } @@ -566,22 +565,9 @@ export class BackendService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getPersonTestResults(workspaceId: number, personId: number): Observable { - return this.http.get[]>( + getPersonTestResults(workspaceId: number, personId: number): Observable { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.http.get( `${this.serverUrl}admin/workspace/${workspaceId}/test-results/${personId}`, { headers: this.authHeader } ); diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index 05dfdc5ce..4318ecfc5 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -91,6 +91,7 @@ interface Unit { alias: string | null; results: UnitResult[]; logs: UnitLog[]; + tags: UnitTagDto[]; } interface Booklet { @@ -171,8 +172,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { private router = inject(Router); private snackBar = inject(MatSnackBar); private translateService = inject(TranslateService); - - // Search debounce private searchSubject = new Subject(); private searchSubscription: Subscription | null = null; private readonly SEARCH_DEBOUNCE_TIME = 800; // milliseconds @@ -181,7 +180,7 @@ export class TestResultsComponent implements OnInit, OnDestroy { dataSource !: MatTableDataSource

; displayedColumns: string[] = ['select', 'code', 'group', 'login', 'uploaded_at']; data: P[] = []; - booklets: Booklet[] = []; + booklets!: Booklet[]; results: { [key: string]: unknown }[] = []; responses: Response[] = []; logs: UnitLog[] = []; @@ -217,7 +216,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - // Clean up subscriptions if (this.searchSubscription) { this.searchSubscription.unsubscribe(); this.searchSubscription = null; @@ -240,9 +238,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { }); } - /** - * Sort units in each booklet alphabetically by alias - */ sortBookletUnits(): void { if (!this.booklets || this.booklets.length === 0) { return; @@ -270,35 +265,24 @@ export class TestResultsComponent implements OnInit, OnDestroy { } /** - * Load tags for all units in all booklets + * Load tags for all units in all booklets from the response */ loadAllUnitTags(): void { if (!this.booklets || this.booklets.length === 0) { return; } - // Collect all unit IDs - const unitIds: number[] = []; + this.unitTagsMap.clear(); + this.booklets.forEach(booklet => { if (booklet.units && Array.isArray(booklet.units)) { booklet.units.forEach(unit => { - if (unit.id) { - unitIds.push(unit.id); + if (unit.id && unit.tags) { + this.unitTagsMap.set(unit.id, unit.tags); } }); } }); - - unitIds.forEach(unitId => { - this.backendService.getUnitTags( - this.appService.selectedWorkspaceId, - unitId - ).subscribe({ - next: tags => { - this.unitTagsMap.set(unitId, tags); - } - }); - }); } replayBooklet() { @@ -438,34 +422,15 @@ export class TestResultsComponent implements OnInit, OnDestroy { // this.loadUnitNotes(); } - loadUnitTags(): void { if (this.selectedUnit && this.selectedUnit.id) { - this.backendService.getUnitTags( - this.appService.selectedWorkspaceId, - this.selectedUnit.id as number - ).subscribe({ - next: tags => { - this.unitTags = tags; - - // Update the unitTagsMap - // @ts-expect-error - Property 'id' may not exist on type '{ alias: string; }' - this.unitTagsMap.set(this.selectedUnit.id as number, tags); - }, - error: () => { - this.snackBar.open( - 'Fehler beim Laden der Tags', - 'Fehler', - { duration: 3000 } - ); - } - }); + const tags = this.unitTagsMap.get(this.selectedUnit.id as number) || []; + this.unitTags = tags; } else { this.unitTags = []; } } - loadUnitNotes(): void { if (this.selectedUnit && this.selectedUnit.id) { this.backendService.getUnitNotes( @@ -596,7 +561,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { }); } - deleteUnitTag(tagId: number): void { if (this.selectedUnit && this.selectedUnit.id) { this.deleteTagFromUnit(tagId, this.selectedUnit.id as number); @@ -668,7 +632,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { return null; } - formatDuration(durationMs: number | null): string { if (durationMs === null || durationMs < 0) return '00:00'; const totalSeconds = Math.floor(durationMs / 1000); From 20488557a622742ae905f791d654ab1fd72d7040 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:08:21 +0200 Subject: [PATCH 15/19] Search for responses --- .../workspace-test-results.controller.ts | 271 ++++++++++++++++ .../workspace-test-results.service.ts | 235 ++++++++++++++ .../src/app/services/backend.service.ts | 153 +++++++++ .../test-results/test-results.component.html | 4 + .../test-results/test-results.component.ts | 13 + .../unit-search-dialog.component.html | 293 ++++++++++++++++++ .../unit-search-dialog.component.scss | 148 +++++++++ .../unit-search-dialog.component.ts | 248 +++++++++++++++ 8 files changed, 1365 insertions(+) create mode 100644 apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html create mode 100644 apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss create mode 100644 apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index 2294ae765..5703b88e7 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -268,6 +268,277 @@ export class WorkspaceTestResultsController { }; } + @Get(':workspace_id/responses/search') + @ApiOperation({ + summary: 'Search for responses', + description: 'Searches for responses across all test persons in a workspace by value, variable ID, and unit name' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'value', + required: false, + description: 'Value to search for in responses', + type: String + }) + @ApiQuery({ + name: 'variableId', + required: false, + description: 'Variable ID to search for', + type: String + }) + @ApiQuery({ + name: 'unitName', + required: false, + description: 'Name of the unit to search for', + type: String + }) + @ApiQuery({ + name: 'status', + required: false, + description: 'Status of the response', + type: String + }) + @ApiQuery({ + name: 'codedStatus', + required: false, + description: 'Coded status of the response', + type: String + }) + @ApiQuery({ + name: 'group', + required: false, + description: 'Group of the person', + type: String + }) + @ApiQuery({ + name: 'code', + required: false, + description: 'Code of the person', + type: String + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number (1-based)', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Responses retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + responseId: { type: 'number', description: 'ID of the response' }, + variableId: { type: 'string', description: 'ID of the variable' }, + value: { type: 'string', description: 'Value of the response' }, + status: { type: 'string', description: 'Status of the response' }, + code: { type: 'number', nullable: true, description: 'Code of the response' }, + score: { type: 'number', nullable: true, description: 'Score of the response' }, + codedStatus: { type: 'string', nullable: true, description: 'Coded status of the response' }, + unitId: { type: 'number', description: 'ID of the unit' }, + unitName: { type: 'string', description: 'Name of the unit' }, + unitAlias: { type: 'string', nullable: true, description: 'Alias of the unit' }, + bookletId: { type: 'number', description: 'ID of the booklet' }, + bookletName: { type: 'string', description: 'Name of the booklet' }, + personId: { type: 'number', description: 'ID of the person' }, + personLogin: { type: 'string', description: 'Login of the person' }, + personCode: { type: 'string', description: 'Code of the person' }, + personGroup: { type: 'string', description: 'Group of the person' } + } + } + }, + total: { type: 'number', description: 'Total number of items' } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to search for responses' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async searchResponses( + @Param('workspace_id') workspace_id: number, + @Query('value') value?: string, + @Query('variableId') variableId?: string, + @Query('unitName') unitName?: string, + @Query('status') status?: string, + @Query('codedStatus') codedStatus?: string, + @Query('group') group?: string, + @Query('code') code?: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }> { + if (!workspace_id || Number.isNaN(workspace_id)) { + throw new BadRequestException('Invalid workspace_id.'); + } + + // No longer require at least one parameter to be provided + + try { + return await this.workspaceTestResultsService.searchResponses( + workspace_id, + { + value, + variableId, + unitName, + status, + codedStatus, + group, + code + }, + { page, limit } + ); + } catch (error) { + logger.error(`Error searching for responses: ${error}`); + throw new BadRequestException(`Failed to search for responses. ${error.message}`); + } + } + + @Get(':workspace_id/units/search') + @ApiOperation({ + summary: 'Search for units by name', + description: 'Searches for units with a specific name across all test persons in a workspace' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'unitName', + required: true, + description: 'Name of the unit to search for', + type: String + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number (1-based)', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Units retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + unitId: { type: 'number', description: 'ID of the unit' }, + unitName: { type: 'string', description: 'Name of the unit' }, + unitAlias: { type: 'string', nullable: true, description: 'Alias of the unit' }, + bookletId: { type: 'number', description: 'ID of the booklet' }, + bookletName: { type: 'string', description: 'Name of the booklet' }, + personId: { type: 'number', description: 'ID of the person' }, + personLogin: { type: 'string', description: 'Login of the person' }, + personCode: { type: 'string', description: 'Code of the person' }, + personGroup: { type: 'string', description: 'Group of the person' }, + tags: { + type: 'array', + description: 'Tags associated with the unit', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + unitId: { type: 'number' }, + tag: { type: 'string' }, + color: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' } + } + } + }, + responses: { + type: 'array', + description: 'Responses associated with the unit', + items: { + type: 'object', + properties: { + variableId: { type: 'string', description: 'ID of the variable' }, + value: { type: 'string', description: 'Value of the response' }, + status: { type: 'string', description: 'Status of the response' }, + code: { type: 'number', nullable: true, description: 'Code of the response' }, + score: { type: 'number', nullable: true, description: 'Score of the response' }, + codedStatus: { type: 'string', nullable: true, description: 'Coded status of the response' } + } + } + } + } + } + }, + total: { type: 'number', description: 'Total number of items' } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to search for units' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findUnitsByName( + @Param('workspace_id') workspace_id: number, + @Query('unitName') unitName: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }> { + if (!workspace_id || Number.isNaN(workspace_id)) { + throw new BadRequestException('Invalid workspace_id.'); + } + + if (!unitName) { + throw new BadRequestException('Unit name is required.'); + } + + try { + return await this.workspaceTestResultsService.findUnitsByName(workspace_id, unitName, { page, limit }); + } catch (error) { + logger.error(`Error searching for units with name ${unitName}: ${error}`); + throw new BadRequestException(`Failed to search for units with name ${unitName}. ${error.message}`); + } + } + @Post(':workspace_id/upload/results/:resultType') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiBearerAuth() diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 59090f575..71c06dfe3 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -380,4 +380,239 @@ export class WorkspaceTestResultsService { return { success: true, report }; }); } + + /** + * Search for responses across all test persons in a workspace + * @param workspaceId The ID of the workspace + * @param searchParams Search parameters (value, variableId, unitName) + * @param options Pagination options + * @returns An array of responses matching the search criteria and total count + */ + async searchResponses( + workspaceId: number, + searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }, + options: { page?: number; limit?: number } = {} + ): Promise<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }> { + if (!workspaceId) { + throw new Error('workspaceId is required.'); + } + + const page = options.page || 1; + const limit = options.limit || 10; + const skip = (page - 1) * limit; + + try { + this.logger.log( + `Searching for responses in workspace: ${workspaceId} with params: ${JSON.stringify(searchParams)} (page: ${page}, limit: ${limit})` + ); + + // Create a query to find responses matching the search criteria + const query = this.responseRepository.createQueryBuilder('response') + .innerJoinAndSelect('response.unit', 'unit') + .innerJoinAndSelect('unit.booklet', 'booklet') + .innerJoinAndSelect('booklet.person', 'person') + .innerJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .where('person.workspace_id = :workspaceId', { workspaceId }); + + // Add search conditions based on provided parameters + if (searchParams.value) { + query.andWhere('response.value ILIKE :value', { value: `%${searchParams.value}%` }); + } + + if (searchParams.variableId) { + query.andWhere('response.variableid ILIKE :variableId', { variableId: `%${searchParams.variableId}%` }); + } + + if (searchParams.unitName) { + query.andWhere('unit.name ILIKE :unitName', { unitName: `%${searchParams.unitName}%` }); + } + + if (searchParams.status) { + query.andWhere('response.status = :status', { status: searchParams.status }); + } + + if (searchParams.codedStatus) { + query.andWhere('response.codedstatus = :codedStatus', { codedStatus: searchParams.codedStatus }); + } + + if (searchParams.group) { + query.andWhere('person.group = :group', { group: searchParams.group }); + } + + if (searchParams.code) { + query.andWhere('person.code = :code', { code: searchParams.code }); + } + + // Get total count + const total = await query.getCount(); + + if (total === 0) { + this.logger.log(`No responses found matching the criteria in workspace: ${workspaceId}`); + return { data: [], total: 0 }; + } + + // Apply pagination + query.skip(skip).take(limit); + + const responses = await query.getMany(); + + this.logger.log(`Found ${total} responses matching the criteria in workspace: ${workspaceId}, returning ${responses.length} for page ${page}`); + + // Map the results to the desired format + const data = responses.map(response => ({ + responseId: response.id, + variableId: response.variableid, + value: response.value || '', + status: response.status, + code: response.code, + score: response.score, + codedStatus: response.codedstatus, + unitId: response.unit.id, + unitName: response.unit.name, + unitAlias: response.unit.alias, + bookletId: response.unit.booklet.id, + bookletName: response.unit.booklet.bookletinfo.name, + personId: response.unit.booklet.person.id, + personLogin: response.unit.booklet.person.login, + personCode: response.unit.booklet.person.code, + personGroup: response.unit.booklet.person.group + })); + + return { data, total }; + } catch (error) { + this.logger.error( + `Failed to search for responses in workspace: ${workspaceId}`, + error.stack + ); + throw new Error(`An error occurred while searching for responses: ${error.message}`); + } + } + + /** + * Find units by name across all test persons in a workspace + * @param workspaceId The ID of the workspace + * @param unitName The name of the unit to search for + * @param options Pagination options + * @returns An array of units with the same name across different test persons and total count + */ + async findUnitsByName( + workspaceId: number, + unitName: string, + options: { page?: number; limit?: number } = {} + ): Promise<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }> { + if (!workspaceId || !unitName) { + throw new Error('Both workspaceId and unitName are required.'); + } + + const page = options.page || 1; + const limit = options.limit || 10; + const skip = (page - 1) * limit; + + try { + this.logger.log( + `Searching for units with name: ${unitName} in workspace: ${workspaceId} (page: ${page}, limit: ${limit})` + ); + + // Create a query to find all units with the given name + const query = this.unitRepository.createQueryBuilder('unit') + .innerJoinAndSelect('unit.booklet', 'booklet') + .innerJoinAndSelect('booklet.person', 'person') + .innerJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .leftJoinAndSelect('unit.responses', 'response') + .where('unit.name = :unitName', { unitName }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }); + + // Get total count + const total = await query.getCount(); + + if (total === 0) { + this.logger.log(`No units found with name: ${unitName} in workspace: ${workspaceId}`); + return { data: [], total: 0 }; + } + + // Apply pagination + query.skip(skip).take(limit); + + const units = await query.getMany(); + + this.logger.log(`Found ${total} units with name: ${unitName} in workspace: ${workspaceId}, returning ${units.length} for page ${page}`); + + // Get tags for all units + const unitIds = units.map(unit => unit.id); + const allUnitTags = await Promise.all( + unitIds.map(unitId => this.unitTagService.findAllByUnitId(unitId)) + ); + + // Create a map of unit ID to tags + const unitTagsMap = new Map(); + unitIds.forEach((unitId, index) => { + unitTagsMap.set(unitId, allUnitTags[index]); + }); + + // Map the results to the desired format + const data = units.map(unit => ({ + unitId: unit.id, + unitName: unit.name, + unitAlias: unit.alias, + bookletId: unit.booklet.id, + bookletName: unit.booklet.bookletinfo.name, + personId: unit.booklet.person.id, + personLogin: unit.booklet.person.login, + personCode: unit.booklet.person.code, + personGroup: unit.booklet.person.group, + tags: unitTagsMap.get(unit.id) || [], + responses: unit.responses ? unit.responses.map(response => ({ + variableId: response.variableid, + value: response.value || '', + status: response.status, + code: response.code, + score: response.score, + codedStatus: response.codedstatus + })) : [] + })); + + return { data, total }; + } catch (error) { + this.logger.error( + `Failed to search for units with name: ${unitName} in workspace: ${workspaceId}`, + error.stack + ); + throw new Error(`An error occurred while searching for units with name: ${unitName}: ${error.message}`); + } + } } diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 4cd5da26a..8f05e09cb 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -697,4 +697,157 @@ export class BackendService { catchError(() => of(null)) ); } + + + searchResponses( + workspaceId: number, + searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }, + page?: number, + limit?: number + ): Observable<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }> { + let params = new HttpParams(); + + if (searchParams.value) { + params = params.set('value', searchParams.value); + } + + if (searchParams.variableId) { + params = params.set('variableId', searchParams.variableId); + } + + if (searchParams.unitName) { + params = params.set('unitName', searchParams.unitName); + } + + if (searchParams.status) { + params = params.set('status', searchParams.status); + } + + if (searchParams.codedStatus) { + params = params.set('codedStatus', searchParams.codedStatus); + } + + if (searchParams.group) { + params = params.set('group', searchParams.group); + } + + if (searchParams.code) { + params = params.set('code', searchParams.code); + } + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/responses/search`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => { + logger.error(`Error searching for responses with params: ${JSON.stringify(searchParams)}`); + return of({ data: [], total: 0 }); + }) + ); + } + + searchUnitsByName( + workspaceId: number, + unitName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }> { + let params = new HttpParams().set('unitName', unitName); + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/units/search`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => { + logger.error(`Error searching for units with name: ${unitName}`); + return of({ data: [], total: 0 }); + }) + ); + } } diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 68c1adedd..ecf07e64b 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -21,6 +21,10 @@ code Kodieren + + search + Suchen +

+

{{ data.title }}

+
+ +
+ + +
+ + + @if (searchMode === 'unit') { +
+ + Aufgabe suchen + + search + +
+ } + + + @if (searchMode === 'response') { + + } + +
+ @if (isLoading) { +
+ +

Suche läuft...

+
+ } @else if (searchMode === 'unit' && unitSearchResults.length === 0 && searchText.trim().length > 2) { +
+ search_off +

Keine Ergebnisse gefunden für "{{ searchText }}"

+
+ } @else if (searchMode === 'response' && responseSearchResults.length === 0 && + (searchValue.trim() !== '' || searchVariableId.trim() !== '' || searchUnitName.trim() !== '' || + searchStatus.trim() !== '' || searchCodedStatus.trim() !== '' || searchGroup.trim() !== '' || + searchCode.trim() !== '')) { +
+ search_off +

Keine Ergebnisse gefunden für die angegebenen Suchkriterien

+
+ } @else if (searchMode === 'unit' && searchText.trim().length <= 2) { +
+ info +

Geben Sie Zeichen ein, um die Suche zu starten

+
+ } @else if (searchMode === 'response' && + searchValue.trim() === '' && + searchVariableId.trim() === '' && + searchUnitName.trim() === '' && + searchStatus.trim() === '' && + searchCodedStatus.trim() === '' && + searchGroup.trim() === '' && + searchCode.trim() === '') { +
+ info +

Geben Sie Zeichen in eines der Suchfelder ein, um die Suche zu starten

+
+ } @else { + + @if (searchMode === 'unit') { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aufgabe{{ unit.unitName }}Alias{{ unit.unitAlias || '-' }}Booklet{{ unit.bookletName }}Login{{ unit.personLogin }}Code{{ unit.personCode }}Gruppe{{ unit.personGroup }}Tags +
+ @for (tag of unit.tags; track tag) { + + {{ tag.tag }} + + } + @if (unit.tags.length === 0) { + Keine Tags + } +
+
Antwort + @if (unit.responses && unit.responses.length > 0) { +
+ @for (response of unit.responses.slice(0, 1); track response) { +
+ {{ response.variableId }}: + {{ response.value | slice:0:50 }}{{ response.value.length > 50 ? '...' : '' }} +
+ } + @if (unit.responses.length > 1) { + +{{ unit.responses.length - 1 }} weitere + } +
+ } @else { + Keine Antwort verfügbar + } +
Aktionen + +
+ } + + + @if (searchMode === 'response') { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Variable ID{{ response.variableId }}Wert + {{ response.value | slice:0:50 }}{{ response.value.length > 50 ? '...' : '' }} + Status{{ response.status }}Kodier Status{{ response.codedStatus }}Aufgabe{{ response.unitName }}Alias{{ response.unitAlias || '-' }}Booklet{{ response.bookletName }}Login{{ response.personLogin }}Code{{ response.personCode }}Gruppe{{ response.personGroup }}Aktionen + +
+ } + + + + + } +
+
+
+ +
+
diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss new file mode 100644 index 000000000..93405c51e --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss @@ -0,0 +1,148 @@ +.unit-search-dialog { + min-width: 800px; + max-width: 1200px; + padding: 20px; +} + +.search-mode-toggle { + display: flex; + justify-content: center; + margin-bottom: 20px; + border-bottom: 1px solid #e0e0e0; + padding-bottom: 10px; + + button { + padding: 8px 16px; + margin: 0 8px; + border-radius: 4px; + + &.active { + background-color: #f0f0f0; + font-weight: bold; + } + } +} + +.search-container { + margin-bottom: 20px; + + .search-field { + width: 100%; + } + + &.response-search { + display: flex; + flex-wrap: wrap; + gap: 16px; + + .search-field { + flex: 1; + min-width: 200px; + } + } +} + +.results-container { + min-height: 300px; + max-height: 500px; + overflow: auto; +} + +.loading-container, .no-results, .search-hint { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 0; + color: #666; + text-align: center; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 16px; + } + + p { + font-size: 16px; + margin: 0; + } +} + +.results-table { + width: 100%; + + .mat-mdc-header-cell { + font-weight: bold; + background-color: #f5f5f5; + } + + .result-row { + cursor: pointer; + + &:hover { + background-color: #f0f0f0; + } + } +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .tag-chip { + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + white-space: nowrap; + } + + .no-tags { + color: #999; + font-style: italic; + font-size: 12px; + } +} + +.response-value { + display: flex; + flex-direction: column; + gap: 4px; + + .response-content { + display: flex; + flex-direction: column; + + .response-variable { + font-weight: bold; + font-size: 12px; + color: #555; + } + + .response-text { + font-size: 13px; + word-break: break-word; + } + } + + .more-responses { + font-size: 12px; + color: #666; + font-style: italic; + } +} + +.no-response { + color: #999; + font-style: italic; + font-size: 12px; +} + +/* Pagination styles */ +mat-paginator { + margin-top: 16px; + border-radius: 4px; + background-color: #f5f5f5; +} diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts new file mode 100644 index 000000000..d83dd5b4e --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts @@ -0,0 +1,248 @@ +import { + Component, Inject, OnInit, ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; + +interface UnitSearchResult { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; +} + +interface ResponseSearchResult { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; +} + +@Component({ + selector: 'coding-box-unit-search-dialog', + templateUrl: './unit-search-dialog.component.html', + styleUrls: ['./unit-search-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTableModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatPaginatorModule, + TranslateModule + ] +}) +export class UnitSearchDialogComponent implements OnInit { + searchText: string = ''; + searchValue: string = ''; + searchVariableId: string = ''; + searchUnitName: string = ''; + searchStatus: string = ''; + searchCodedStatus: string = ''; + searchGroup: string = ''; + searchCode: string = ''; + + searchMode: 'unit' | 'response' = 'unit'; + + unitSearchResults: UnitSearchResult[] = []; + responseSearchResults: ResponseSearchResult[] = []; + + isLoading: boolean = false; + unitDisplayedColumns: string[] = ['unitName', 'unitAlias', 'bookletName', 'personLogin', 'personCode', 'personGroup', 'tags', 'responseValue', 'actions']; + responseDisplayedColumns: string[] = ['variableId', 'value', 'status', 'codedStatus', 'unitName', 'unitAlias', 'bookletName', 'personLogin', 'personCode', 'personGroup', 'actions']; + + private unitSearchSubject = new Subject(); + private responseSearchSubject = new Subject<{ value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }>(); + private readonly SEARCH_DEBOUNCE_TIME = 500; + + totalItems: number = 0; + pageSize: number = 10; + pageIndex: number = 0; + pageSizeOptions: number[] = [50, 100, 200, 500]; + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { title: string }, + private backendService: BackendService, + private appService: AppService, + private router: Router + ) {} + + ngOnInit(): void { + this.unitSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.pageIndex = 0; // Reset to first page on new search + this.searchUnits(searchText); + }); + + this.responseSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged((prev, curr) => prev.value === curr.value && prev.variableId === curr.variableId && prev.unitName === curr.unitName && prev.status === curr.status && prev.codedStatus === curr.codedStatus && prev.group === curr.group && prev.code === curr.code) + ).subscribe(searchParams => { + this.pageIndex = 0; // Reset to first page on new search + this.searchResponses(searchParams); + }); + } + + onUnitSearchChange(): void { + if (this.searchText.trim().length > 2) { + this.unitSearchSubject.next(this.searchText); + } + } + + onResponseSearchChange(): void { + this.responseSearchSubject.next({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + + if (this.searchMode === 'unit') { + this.searchUnits(this.searchText); + } else { + this.searchResponses({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + } + } + + toggleSearchMode(): void { + this.searchMode = this.searchMode === 'unit' ? 'response' : 'unit'; + this.pageIndex = 0; + this.totalItems = 0; + this.unitSearchResults = []; + this.responseSearchResults = []; + } + + searchUnits(unitName: string): void { + if (!unitName || unitName.trim().length < 3) { + this.unitSearchResults = []; + this.totalItems = 0; + return; + } + + this.isLoading = true; + this.backendService.searchUnitsByName( + this.appService.selectedWorkspaceId, + unitName, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.unitSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.unitSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + + searchResponses(searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }): void { + this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing + this.backendService.searchResponses( + this.appService.selectedWorkspaceId, + searchParams, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.responseSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.responseSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + + close(): void { + this.dialogRef.close(); + } + + /** + * Replays a unit in a new tab + * @param item The unit or response to replay + */ + replayUnit(item: UnitSearchResult | ResponseSearchResult): void { + this.appService + .createToken(this.appService.selectedWorkspaceId, this.appService.loggedUser?.sub || '', 1) + .subscribe(token => { + const queryParams = { + auth: token + }; + const url = this.router + .serializeUrl( + this.router.createUrlTree( + [`replay/${item.personLogin}@${item.personCode}@${item.personGroup}/${item.unitAlias}/0/0`], + { queryParams: queryParams }) + ); + window.open(`#/${url}`, '_blank'); + }); + } +} From 5c29944751cf1f0229a9dc64591e152ff3d97d6d Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:17:02 +0200 Subject: [PATCH 16/19] Delete searched responses or units --- .../workspace-test-results.controller.ts | 114 ++++++++ .../workspace-test-results.service.ts | 156 +++++++++++ .../src/app/services/backend.service.ts | 161 ++++++++++- .../unit-search-dialog.component.html | 24 ++ .../unit-search-dialog.component.scss | 30 ++ .../unit-search-dialog.component.ts | 261 +++++++++++++++++- 6 files changed, 742 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index 5703b88e7..ec5afb1f3 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -183,6 +183,120 @@ export class WorkspaceTestResultsController { return this.workspaceTestResultsService.deleteTestPersons(Number(workspaceId), testPersonIds); } + @Delete(':workspace_id/units/:unitId') + @ApiOperation({ + summary: 'Delete a unit', + description: 'Deletes a unit and all its associated responses' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'unitId', type: Number, description: 'ID of the unit to delete' }) + @ApiOkResponse({ + description: 'Unit deleted successfully.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + report: { + type: 'object', + properties: { + deletedUnit: { type: 'number', nullable: true }, + warnings: { type: 'array', items: { type: 'string' } } + } + } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to delete unit' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteUnit( + @Param('workspace_id') workspaceId: number, + @Param('unitId') unitId: number + ): Promise<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.workspaceTestResultsService.deleteUnit(workspaceId, unitId); + } + + @Delete(':workspace_id/responses/:responseId') + @ApiOperation({ + summary: 'Delete a response', + description: 'Deletes a response' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'responseId', type: Number, description: 'ID of the response to delete' }) + @ApiOkResponse({ + description: 'Response deleted successfully.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + report: { + type: 'object', + properties: { + deletedResponse: { type: 'number', nullable: true }, + warnings: { type: 'array', items: { type: 'string' } } + } + } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to delete response' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteResponse( + @Param('workspace_id') workspaceId: number, + @Param('responseId') responseId: number + ): Promise<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }> { + return this.workspaceTestResultsService.deleteResponse(workspaceId, responseId); + } + + @Delete(':workspace_id/booklets/:bookletId') + @ApiOperation({ + summary: 'Delete a booklet', + description: 'Deletes a booklet and all its associated units and responses' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'bookletId', type: Number, description: 'ID of the booklet to delete' }) + @ApiOkResponse({ + description: 'Booklet deleted successfully.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + report: { + type: 'object', + properties: { + deletedBooklet: { type: 'number', nullable: true }, + warnings: { type: 'array', items: { type: 'string' } } + } + } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to delete booklet' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteBooklet( + @Param('workspace_id') workspaceId: number, + @Param('bookletId') bookletId: number + ): Promise<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }> { + return this.workspaceTestResultsService.deleteBooklet(workspaceId, bookletId); + } + @Get(':workspace_id/responses') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 71c06dfe3..fceab2924 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -381,6 +381,162 @@ export class WorkspaceTestResultsService { }); } + /** + * Delete a unit and all its associated responses + * @param workspaceId The ID of the workspace + * @param unitId The ID of the unit to delete + * @returns A success flag and a report with deleted unit and warnings + */ + async deleteUnit( + workspaceId: number, + unitId: number + ): Promise<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.connection.transaction(async manager => { + const report = { + deletedUnit: null, + warnings: [] + }; + + // Check if the unit exists and belongs to the workspace + const unit = await manager + .createQueryBuilder(Unit, 'unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .where('unit.id = :unitId', { unitId }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }) + .getOne(); + + if (!unit) { + const warningMessage = `Keine Unit mit ID ${unitId} im Workspace ${workspaceId} gefunden`; + this.logger.warn(warningMessage); + report.warnings.push(warningMessage); + return { success: false, report }; + } + + // Delete the unit (cascade will delete associated responses) + await manager + .createQueryBuilder() + .delete() + .from(Unit) + .where('id = :unitId', { unitId }) + .execute(); + + report.deletedUnit = unitId; + + return { success: true, report }; + }); + } + + /** + * Delete a response + * @param workspaceId The ID of the workspace + * @param responseId The ID of the response to delete + * @returns A success flag and a report with deleted response and warnings + */ + async deleteResponse( + workspaceId: number, + responseId: number + ): Promise<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }> { + return this.connection.transaction(async manager => { + const report = { + deletedResponse: null, + warnings: [] + }; + + // Check if the response exists and belongs to the workspace + const response = await manager + .createQueryBuilder(ResponseEntity, 'response') + .leftJoinAndSelect('response.unit', 'unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .where('response.id = :responseId', { responseId }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }) + .getOne(); + + if (!response) { + const warningMessage = `Keine Antwort mit ID ${responseId} im Workspace ${workspaceId} gefunden`; + this.logger.warn(warningMessage); + report.warnings.push(warningMessage); + return { success: false, report }; + } + + // Delete the response + await manager + .createQueryBuilder() + .delete() + .from(ResponseEntity) + .where('id = :responseId', { responseId }) + .execute(); + + report.deletedResponse = responseId; + + return { success: true, report }; + }); + } + + /** + * Delete a booklet and all its associated units and responses + * @param workspaceId The ID of the workspace + * @param bookletId The ID of the booklet to delete + * @returns A success flag and a report with deleted booklet and warnings + */ + async deleteBooklet( + workspaceId: number, + bookletId: number + ): Promise<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }> { + return this.connection.transaction(async manager => { + const report = { + deletedBooklet: null, + warnings: [] + }; + + // Check if the booklet exists and belongs to the workspace + const booklet = await manager + .createQueryBuilder(Booklet, 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .where('booklet.id = :bookletId', { bookletId }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }) + .getOne(); + + if (!booklet) { + const warningMessage = `Kein Booklet mit ID ${bookletId} im Workspace ${workspaceId} gefunden`; + this.logger.warn(warningMessage); + report.warnings.push(warningMessage); + return { success: false, report }; + } + + // Delete the booklet (cascade will delete associated units and responses) + await manager + .createQueryBuilder() + .delete() + .from(Booklet) + .where('id = :bookletId', { bookletId }) + .execute(); + + report.deletedBooklet = bookletId; + + return { success: true, report }; + }); + } + /** * Search for responses across all test persons in a workspace * @param workspaceId The ID of the workspace diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 8f05e09cb..53381c807 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { - catchError, map, Observable, of, switchMap + catchError, forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { logger } from 'nx/src/utils/logger'; import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; @@ -698,7 +698,6 @@ export class BackendService { ); } - searchResponses( workspaceId: number, searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }, @@ -850,4 +849,162 @@ export class BackendService { }) ); } + + /** + * Delete a unit and all its associated responses + * @param workspaceId The ID of the workspace + * @param unitId The ID of the unit to delete + * @returns An Observable of the deletion result + */ + deleteUnit(workspaceId: number, unitId: number): Observable<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/units/${unitId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => { + logger.error(`Error deleting unit with ID: ${unitId}`); + return of({ success: false, report: { deletedUnit: null, warnings: ['Failed to delete unit'] } }); + }) + ); + } + + /** + * Delete multiple units and all their associated responses + * @param workspaceId The ID of the workspace + * @param unitIds Array of unit IDs to delete + * @returns An Observable of the deletion result + */ + deleteMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ + success: boolean; + report: { + deletedUnits: number[]; + warnings: string[]; + }; + }> { + // Create a series of delete requests for each unit + const deleteRequests = unitIds.map(unitId => this.deleteUnit(workspaceId, unitId)); + + // Combine all requests and aggregate the results + return forkJoin(deleteRequests).pipe( + map(results => { + const successfulDeletes = results.filter(result => result.success); + const deletedUnits = successfulDeletes + .map(result => result.report.deletedUnit) + .filter(id => id !== null) as number[]; + + const warnings = results + .filter(result => !result.success || result.report.warnings.length > 0) + .flatMap(result => result.report.warnings); + + return { + success: deletedUnits.length > 0, + report: { + deletedUnits, + warnings + } + }; + }), + catchError(() => { + logger.error('Error deleting multiple units'); + return of({ + success: false, + report: { + deletedUnits: [], + warnings: ['Failed to delete units'] + } + }); + }) + ); + } + + /** + * Delete a response + * @param workspaceId The ID of the workspace + * @param responseId The ID of the response to delete + * @returns An Observable of the deletion result + */ + deleteResponse(workspaceId: number, responseId: number): Observable<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/responses/${responseId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => { + logger.error(`Error deleting response with ID: ${responseId}`); + return of({ success: false, report: { deletedResponse: null, warnings: ['Failed to delete response'] } }); + }) + ); + } + + /** + * Delete multiple responses + * @param workspaceId The ID of the workspace + * @param responseIds Array of response IDs to delete + * @returns An Observable of the deletion result + */ + deleteMultipleResponses(workspaceId: number, responseIds: number[]): Observable<{ + success: boolean; + report: { + deletedResponses: number[]; + warnings: string[]; + }; + }> { + // Create a series of delete requests for each response + const deleteRequests = responseIds.map(responseId => this.deleteResponse(workspaceId, responseId)); + + // Combine all requests and aggregate the results + return forkJoin(deleteRequests).pipe( + map(results => { + const successfulDeletes = results.filter(result => result.success); + const deletedResponses = successfulDeletes + .map(result => result.report.deletedResponse) + .filter(id => id !== null) as number[]; + + const warnings = results + .filter(result => !result.success || result.report.warnings.length > 0) + .flatMap(result => result.report.warnings); + + return { + success: deletedResponses.length > 0, + report: { + deletedResponses, + warnings + } + }; + }), + catchError(() => { + logger.error('Error deleting multiple responses'); + return of({ + success: false, + report: { + deletedResponses: [], + warnings: ['Failed to delete responses'] + } + }); + }) + ); + } } diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html index 588eaf05d..8244e0c28 100644 --- a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html @@ -104,6 +104,15 @@

{{ data.title }}

} @else { @if (searchMode === 'unit') { + + @if (unitSearchResults.length > 0) { +
+ +
+ } @@ -187,6 +196,9 @@

{{ data.title }}

+
@@ -197,6 +209,15 @@

{{ data.title }}

@if (searchMode === 'response') { + + @if (responseSearchResults.length > 0) { +
+ +
+ }
@@ -267,6 +288,9 @@

{{ data.title }}

+
diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss index 93405c51e..17ff79560 100644 --- a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.scss @@ -85,6 +85,12 @@ background-color: #f0f0f0; } } + + td[mat-cell]:last-child { + display: flex; + justify-content: flex-end; + gap: 8px; + } } .tags-container { @@ -140,6 +146,30 @@ font-size: 12px; } +/* Delete All Button styles */ +.delete-all-container { + margin-bottom: 16px; + display: flex; + justify-content: flex-start; + + button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + font-size: 12px; + color: #666; + border-color: #ccc; + + mat-icon { + margin-right: 2px; + font-size: 18px; + height: 18px; + width: 18px; + } + } +} + /* Pagination styles */ mat-paginator { margin-top: 16px; diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts index d83dd5b4e..0fdc6e8da 100644 --- a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.ts @@ -4,7 +4,12 @@ import { import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -12,11 +17,13 @@ import { MatTableModule } from '@angular/material/table'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateModule } from '@ngx-translate/core'; import { Router } from '@angular/router'; import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; import { BackendService } from '../../../services/backend.service'; import { AppService } from '../../../services/app.service'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; interface UnitSearchResult { unitId: number; @@ -106,7 +113,9 @@ export class UnitSearchDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) public data: { title: string }, private backendService: BackendService, private appService: AppService, - private router: Router + private router: Router, + private dialog: MatDialog, + private snackBar: MatSnackBar ) {} ngOnInit(): void { @@ -245,4 +254,252 @@ export class UnitSearchDialogComponent implements OnInit { window.open(`#/${url}`, '_blank'); }); } + + /** + * Deletes a unit and all its associated responses + * @param unit The unit to delete + */ + deleteUnit(unit: UnitSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Unit löschen', + content: `Sind Sie sicher, dass Sie die Unit "${unit.unitName}" (${unit.unitAlias || 'ohne Alias'}) löschen möchten? Alle zugehörigen Antworten werden ebenfalls gelöscht.`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteUnit( + this.appService.selectedWorkspaceId, + unit.unitId + ).subscribe({ + next: response => { + this.isLoading = false; + if (response.success) { + this.unitSearchResults = this.unitSearchResults.filter(u => u.unitId !== unit.unitId); + this.totalItems -= 1; + this.snackBar.open( + `Unit erfolgreich gelöscht. Unit ID: ${response.report.deletedUnit}`, + 'Schließen', + { duration: 3000 } + ); + } else { + this.snackBar.open( + `Fehler beim Löschen der Unit: ${response.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Unit. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + /** + * Deletes a response + * @param response The response to delete + */ + deleteResponse(response: ResponseSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Antwort löschen', + content: `Sind Sie sicher, dass Sie die Antwort für Variable "${response.variableId}" löschen möchten?`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteResponse( + this.appService.selectedWorkspaceId, + response.responseId + ).subscribe({ + next: apiResponse => { + this.isLoading = false; + if (apiResponse.success) { + // Remove the response from the results + this.responseSearchResults = this.responseSearchResults.filter(r => r.responseId !== response.responseId); + // Update total count + this.totalItems -= 1; + // Show success message + this.snackBar.open( + `Antwort erfolgreich gelöscht. Antwort ID: ${apiResponse.report.deletedResponse}`, + 'Schließen', + { duration: 3000 } + ); + } else { + // Show error message + this.snackBar.open( + `Fehler beim Löschen der Antwort: ${apiResponse.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Antwort. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + /** + * Deletes all filtered units + */ + deleteAllUnits(): void { + if (this.unitSearchResults.length === 0) { + this.snackBar.open( + 'Keine Aufgaben zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Aufgaben löschen', + content: `Sind Sie sicher, dass Sie alle ${this.unitSearchResults.length} gefilterten Aufgaben löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + const unitIds = this.unitSearchResults.map(unit => unit.unitId); + + this.backendService.deleteMultipleUnits( + this.appService.selectedWorkspaceId, + unitIds + ).subscribe({ + next: response => { + this.isLoading = false; + if (response.success) { + const deletedCount = response.report.deletedUnits.length; + + // Clear the search results + this.unitSearchResults = []; + this.totalItems = 0; + + // Show success message + this.snackBar.open( + `${deletedCount} Aufgaben erfolgreich gelöscht.`, + 'Schließen', + { duration: 3000 } + ); + } else { + // Show error message + this.snackBar.open( + `Fehler beim Löschen der Aufgaben: ${response.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Aufgaben. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + /** + * Deletes all filtered responses + */ + deleteAllResponses(): void { + if (this.responseSearchResults.length === 0) { + this.snackBar.open( + 'Keine Antworten zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Antworten löschen', + content: `Sind Sie sicher, dass Sie alle ${this.responseSearchResults.length} gefilterten Antworten löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + const responseIds = this.responseSearchResults.map(response => response.responseId); + + this.backendService.deleteMultipleResponses( + this.appService.selectedWorkspaceId, + responseIds + ).subscribe({ + next: response => { + this.isLoading = false; + if (response.success) { + const deletedCount = response.report.deletedResponses.length; + + // Clear the search results + this.responseSearchResults = []; + this.totalItems = 0; + + // Show success message + this.snackBar.open( + `${deletedCount} Antworten erfolgreich gelöscht.`, + 'Schließen', + { duration: 3000 } + ); + } else { + // Show error message + this.snackBar.open( + `Fehler beim Löschen der Antworten: ${response.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Antworten. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } } From 2878b24dd470380cc3849e8b0c77c3c918f38f22 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:35:17 +0200 Subject: [PATCH 17/19] Improve ui for app-info component --- apps/frontend/src/app/app.component.html | 2 +- .../app-info/app-info.component.html | 44 ++++-- .../app-info/app-info.component.scss | 129 ++++++++++++++++-- 3 files changed, 148 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/app/app.component.html b/apps/frontend/src/app/app.component.html index 1d7a8418f..b02a2d0ac 100755 --- a/apps/frontend/src/app/app.component.html +++ b/apps/frontend/src/app/app.component.html @@ -22,7 +22,7 @@ class="app-title" [class.margin-logged-in]="authService.isLoggedIn()" [class.margin-logged-out]="!authService.isLoggedIn()"> - Kodierbox + IQB-Kodierbox diff --git a/apps/frontend/src/app/components/app-info/app-info.component.html b/apps/frontend/src/app/components/app-info/app-info.component.html index af4e0b190..8ef000210 100755 --- a/apps/frontend/src/app/components/app-info/app-info.component.html +++ b/apps/frontend/src/app/components/app-info/app-info.component.html @@ -1,19 +1,35 @@
-

Das Institut zur Qualitätsentwicklung im Bildungswesen (IQB) betreibt auf diesen Seiten eine Anwendung für die Kodierung von Antworten aus Testungen.

-
    -
  • Version {{'home.appVersion' | translate: {version: appVersion()} }}
  • - @if (isUserLoggedIn()) { -
  • - {{'Angemeldet als: ' + userName() }} -
  • - } - @if (isAdmin()) { -
  • {{'home.isAdminMessage' | translate}}
  • - } -
-
+
+

+ Das Institut zur Qualitätsentwicklung im Bildungswesen (IQB) + betreibt auf diesen Seiten eine Anwendung für die Kodierung von Antworten aus Testungen. +

+ +
+
    +
  • + Version: + {{'home.appVersion' | translate: {version: appVersion()} }} +
  • + @if (isUserLoggedIn()) { +
  • + Benutzer: + {{ userName() }} +
  • + } + @if (isAdmin()) { +
  • + Status: + {{'home.isAdminMessage' | translate}} +
  • + } +
+
+
+ +
diff --git a/apps/frontend/src/app/components/app-info/app-info.component.scss b/apps/frontend/src/app/components/app-info/app-info.component.scss index 005b82237..28914005d 100755 --- a/apps/frontend/src/app/components/app-info/app-info.component.scss +++ b/apps/frontend/src/app/components/app-info/app-info.component.scss @@ -1,23 +1,128 @@ +$iqb-accent: rgb(0, 96, 100); + + .scroll-area { overflow-y: auto; } -.app-info{ +.app-info { overflow-y: auto; - background: lightgray; - border-radius: 10px; - padding: 50px; + background: lightgray; /* Keeping the original background color as specified */ + border-radius: 12px; + padding: 40px; font-size: 18px; - line-height: 20pt; + line-height: 1.6; height: 100%; -} + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05); -.impressum{ - width:100%; - padding: 20px; -} + .content { + flex: 1; + } + + .app-title { + font-size: 28px; + font-weight: 600; + color: $iqb-accent; + margin-bottom: 20px; + letter-spacing: -0.5px; + border-bottom: 2px solid rgba($iqb-accent, 0.2); + padding-bottom: 10px; + } + + p { + margin-bottom: 25px; + color: #333; + font-weight: 400; + + a { + color: $iqb-accent; + text-decoration: none; + font-weight: 500; + transition: color 0.2s ease; + + &:hover { + color: darken($iqb-accent, 10%); + text-decoration: underline; + } + } + } + + .info-card { + background-color: rgba(255, 255, 255, 0.6); + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + + ul { + list-style-type: none; + padding-left: 0; + margin-bottom: 0; -.impressum a{ - width:100%; + li { + padding: 10px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + &:first-child { + padding-top: 0; + } + } + } + + .info-label { + font-weight: 600; + color: #555; + width: 100px; + flex-shrink: 0; + } + + .info-value { + color: #333; + } + + .admin-badge { + background-color: $iqb-accent; + color: white; + padding: 3px 10px; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + } + } } +.impressum { + width: 100%; + padding: 20px 0; + margin-top: auto; + + a { + width: auto; + min-width: 200px; + border-radius: 25px; + padding: 10px 24px; + font-weight: 500; + letter-spacing: 0.5px; + transition: all 0.3s ease; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } + + .button-text { + font-weight: 500; + } + } +} From 9de9c22d7e945c115f69645a258e55b9cac6af33 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:38:29 +0200 Subject: [PATCH 18/19] Set version to 0.7.0 --- apps/frontend/src/app/components/home/home.component.html | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index fb652f8dc..8902d914a 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.6.1'" + [appVersion]="'0.7.0'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/package-lock.json b/package-lock.json index c01b30440..d9e03db0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index 4ebee4b6d..4a0dd3715 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.6.1", + "version": "0.7.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": { From 850312bd9064c572a5a148617323822afb9de00c Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:54:13 +0200 Subject: [PATCH 19/19] Fix double definitions --- .../components/test-results/test-results.component.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index 7f3d3e095..6875ec249 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -176,12 +176,7 @@ export class TestResultsComponent implements OnInit, OnDestroy { private translateService = inject(TranslateService); private searchSubject = new Subject(); private searchSubscription: Subscription | null = null; - private readonly SEARCH_DEBOUNCE_TIME = 800; // milliseconds - - // Search debounce - private searchSubject = new Subject(); - private searchSubscription: Subscription | null = null; - private readonly SEARCH_DEBOUNCE_TIME = 800; // milliseconds + private readonly SEARCH_DEBOUNCE_TIME = 800; selection = new SelectionModel

(true, []); dataSource !: MatTableDataSource

; @@ -620,7 +615,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { return date.toLocaleString(); } - calculateBookletProcessingTime(booklet: Booklet): number | null { if (!booklet.logs || !Array.isArray(booklet.logs) || booklet.logs.length === 0) { return null; @@ -700,7 +694,6 @@ export class TestResultsComponent implements OnInit, OnDestroy { } } - getCurrentSearchText(): string { const searchInput = document.querySelector('.search-input') as HTMLInputElement; return searchInput ? searchInput.value : '';