From 3d7cd47594ce7c42ceb3d8567edeec8b4239abe4 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:46:31 +0200 Subject: [PATCH 1/8] Implements coding job management --- .../admin/coding-job/coding-job.controller.ts | 340 ++++++++++++++ .../admin/coding-job/dto/assign-coders.dto.ts | 16 + .../admin/coding-job/dto/coding-job.dto.ts | 92 ++++ .../coding-job/dto/create-coding-job.dto.ts | 87 ++++ .../coding-job/dto/update-coding-job.dto.ts | 91 ++++ .../dto/simple-variable-bundle.dto.ts | 52 +++ apps/backend/src/app/app.module.ts | 3 +- .../src/app/database/database.module.ts | 20 +- .../entities/coding-job-coder.entity.ts | 31 ++ .../coding-job-variable-bundle.entity.ts | 36 ++ .../entities/coding-job-variable.entity.ts | 34 ++ .../database/entities/coding-job.entity.ts | 38 ++ .../database/services/coding-job.service.ts | 339 ++++++++++++++ .../coding-job/coding-job.controller.ts | 284 ++++++++++++ .../wsg-admin/coding-job/coding-job.module.ts | 27 ++ .../src/app/wsg-admin/wsg-admin.module.ts | 10 + .../coding-job-dialog.component.html | 312 +++++++------ .../coding-job-dialog.component.scss | 334 ++++++++++++++ .../coding-job-dialog.component.ts | 90 ++-- .../coding-jobs/coding-jobs.component.html | 29 +- .../coding-jobs/coding-jobs.component.scss | 40 ++ .../coding-jobs/coding-jobs.component.ts | 421 ++++++++++++++---- .../coding-management-manual.component.html | 18 +- .../coding-management-manual.component.ts | 41 +- .../variable-bundle-dialog.component.html | 9 +- .../variable-bundle-dialog.component.ts | 67 ++- .../variable-bundle-manager.component.html | 1 + .../variable-bundle-manager.component.ts | 2 +- .../src/app/coding/models/coding-job.model.ts | 1 + .../src/app/services/backend.service.ts | 96 +++- .../confirm-dialog.component.html | 8 + .../confirm-dialog.component.ts | 36 ++ .../search-filter.component.html | 5 +- .../search-filter/search-filter.component.ts | 54 ++- .../changelog/coding-box.changelog-0.12.0.sql | 2 +- .../changelog/coding-box.changelog-0.13.0.sql | 60 +++ .../changelog/coding-box.changelog-root.xml | 1 + 37 files changed, 2806 insertions(+), 321 deletions(-) create mode 100644 apps/backend/src/app/admin/coding-job/coding-job.controller.ts create mode 100644 apps/backend/src/app/admin/coding-job/dto/assign-coders.dto.ts create mode 100644 apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts create mode 100644 apps/backend/src/app/admin/coding-job/dto/create-coding-job.dto.ts create mode 100644 apps/backend/src/app/admin/coding-job/dto/update-coding-job.dto.ts create mode 100644 apps/backend/src/app/admin/variable-bundle/dto/simple-variable-bundle.dto.ts create mode 100644 apps/backend/src/app/database/entities/coding-job-coder.entity.ts create mode 100644 apps/backend/src/app/database/entities/coding-job-variable-bundle.entity.ts create mode 100644 apps/backend/src/app/database/entities/coding-job-variable.entity.ts create mode 100644 apps/backend/src/app/database/entities/coding-job.entity.ts create mode 100644 apps/backend/src/app/database/services/coding-job.service.ts create mode 100644 apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts create mode 100644 apps/backend/src/app/wsg-admin/coding-job/coding-job.module.ts create mode 100644 apps/backend/src/app/wsg-admin/wsg-admin.module.ts create mode 100644 apps/frontend/src/app/shared/confirm-dialog/confirm-dialog.component.html create mode 100644 apps/frontend/src/app/shared/confirm-dialog/confirm-dialog.component.ts create mode 100644 database/changelog/coding-box.changelog-0.13.0.sql diff --git a/apps/backend/src/app/admin/coding-job/coding-job.controller.ts b/apps/backend/src/app/admin/coding-job/coding-job.controller.ts new file mode 100644 index 000000000..daf296e65 --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/coding-job.controller.ts @@ -0,0 +1,340 @@ +import { + BadRequestException, + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + NotFoundException, + Param, + ParseIntPipe, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from '../workspace/workspace.guard'; +import { WorkspaceId } from '../workspace/workspace.decorator'; +import { CodingJobService } from '../../database/services/coding-job.service'; +import { CodingJobDto } from './dto/coding-job.dto'; +import { CreateCodingJobDto } from './dto/create-coding-job.dto'; +import { UpdateCodingJobDto } from './dto/update-coding-job.dto'; +import { AssignCodersDto } from './dto/assign-coders.dto'; +import { VariableBundleDto } from '../variable-bundle/dto/variable-bundle.dto'; +import { VariableDto } from '../variable-bundle/dto/variable.dto'; + +@ApiTags('Admin Coding Jobs') +@Controller('admin/workspace/:workspace_id/coding-job') +export class CodingJobController { + constructor(private readonly codingJobService: CodingJobService) {} + + @Get() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all coding jobs', + description: 'Retrieves all coding jobs for a workspace with pagination' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'Unique identifier for the workspace' + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'List of coding jobs retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/CodingJobDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getCodingJobs( + @WorkspaceId() workspaceId: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise<{ data: CodingJobDto[]; total: number; page: number; limit: number }> { + try { + const result = await this.codingJobService.getCodingJobs(workspaceId, page, limit); + return { + data: result.data.map(job => CodingJobDto.fromEntity(job)), + total: result.total, + page: result.page, + limit: result.limit + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve coding jobs: ${error.message}`); + } + } + + @Get(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a coding job by ID', + description: 'Retrieves a coding job by ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully retrieved.', + type: CodingJobDto + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + async getCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number + ): Promise { + try { + const result = await this.codingJobService.getCodingJob(id, workspaceId); + const dto = CodingJobDto.fromEntity(result.codingJob); + dto.assigned_coders = result.assignedCoders; + dto.variables = result.variables.map(v => { + const variableDto = new VariableDto(); + variableDto.unitName = v.unitName; + variableDto.variableId = v.variableId; + return variableDto; + }); + dto.variable_bundles = result.variableBundles.map(vb => VariableBundleDto.fromEntity(vb)); + return dto; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve coding job: ${error.message}`); + } + } + + @Post() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new coding job', + description: 'Creates a new coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiCreatedResponse({ + description: 'The coding job has been successfully created.', + type: CodingJobDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async createCodingJob( + @WorkspaceId() workspaceId: number, + @Body() createCodingJobDto: CreateCodingJobDto + ): Promise { + try { + const codingJob = await this.codingJobService.createCodingJob( + workspaceId, + createCodingJobDto + ); + return CodingJobDto.fromEntity(codingJob); + } catch (error) { + throw new BadRequestException(`Failed to create coding job: ${error.message}`); + } + } + + @Put(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a coding job', + description: 'Updates a coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully updated.', + type: CodingJobDto + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async updateCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number, + @Body() updateCodingJobDto: UpdateCodingJobDto + ): Promise { + try { + const codingJob = await this.codingJobService.updateCodingJob( + id, + workspaceId, + updateCodingJobDto + ); + return CodingJobDto.fromEntity(codingJob); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to update coding job: ${error.message}`); + } + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a coding job', + description: 'Deletes a coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully deleted.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + async deleteCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number + ): Promise<{ success: boolean }> { + try { + return await this.codingJobService.deleteCodingJob(id, workspaceId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to delete coding job: ${error.message}`); + } + } + + @Post(':id/assign-coders') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Assign coders to a coding job', + description: 'Assigns coders to a coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'Coders have been successfully assigned to the coding job.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async assignCoders( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number, + @Body() assignCodersDto: AssignCodersDto + ): Promise<{ success: boolean }> { + try { + // Verify the coding job exists in this workspace + await this.codingJobService.getCodingJob(id, workspaceId); + + // Assign the coders + await this.codingJobService.assignCoders(id, assignCodersDto.userIds); + + return { success: true }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to assign coders: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/coding-job/dto/assign-coders.dto.ts b/apps/backend/src/app/admin/coding-job/dto/assign-coders.dto.ts new file mode 100644 index 000000000..39d978fab --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/assign-coders.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNumber } from 'class-validator'; + +/** + * DTO for assigning coders to a coding job + */ +export class AssignCodersDto { + @ApiProperty({ + description: 'Array of user IDs to assign as coders', + type: [Number], + example: [1, 2, 3] + }) + @IsArray() + @IsNumber({}, { each: true }) + userIds: number[]; +} diff --git a/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts b/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts new file mode 100644 index 000000000..98145c3cc --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts @@ -0,0 +1,92 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CodingJob } from '../../../database/entities/coding-job.entity'; +import { VariableBundleDto } from '../../variable-bundle/dto/variable-bundle.dto'; +import { VariableDto } from '../../variable-bundle/dto/variable.dto'; + +/** + * DTO for a coding job + */ +export class CodingJobDto { + @ApiProperty({ + description: 'Unique identifier for the coding job', + example: 1 + }) + id: number; + + @ApiProperty({ + description: 'Workspace ID the coding job belongs to', + example: 1 + }) + workspace_id: number; + + @ApiProperty({ + description: 'Name of the coding job', + example: 'Coding Job 1' + }) + name: string; + + @ApiProperty({ + description: 'Description of the coding job', + example: 'This is a coding job for testing', + required: false + }) + description?: string; + + @ApiProperty({ + description: 'Status of the coding job', + example: 'pending', + enum: ['pending', 'active', 'completed'] + }) + status: string; + + @ApiProperty({ + description: 'Date and time when the coding job was created', + example: '2025-08-06T10:05:00.000Z' + }) + created_at: Date; + + @ApiProperty({ + description: 'Date and time when the coding job was last updated', + example: '2025-08-06T10:05:00.000Z' + }) + updated_at: Date; + + @ApiProperty({ + description: 'IDs of coders assigned to the coding job', + type: [Number], + example: [1, 2, 3], + required: false + }) + assigned_coders?: number[]; + + @ApiProperty({ + description: 'Variables assigned to the coding job', + type: [VariableDto], + required: false + }) + variables?: VariableDto[]; + + @ApiProperty({ + description: 'Variable bundles assigned to the coding job', + type: [VariableBundleDto], + required: false + }) + variable_bundles?: VariableBundleDto[]; + + /** + * Create a CodingJobDto from a CodingJob entity + * @param entity The CodingJob entity + * @returns A CodingJobDto + */ + static fromEntity(entity: CodingJob): CodingJobDto { + const dto = new CodingJobDto(); + dto.id = entity.id; + dto.workspace_id = entity.workspace_id; + dto.name = entity.name; + dto.description = entity.description; + dto.status = entity.status; + dto.created_at = entity.created_at; + dto.updated_at = entity.updated_at; + return dto; + } +} diff --git a/apps/backend/src/app/admin/coding-job/dto/create-coding-job.dto.ts b/apps/backend/src/app/admin/coding-job/dto/create-coding-job.dto.ts new file mode 100644 index 000000000..8d58d78a6 --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/create-coding-job.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsArray, + IsEnum, + IsNumber, + ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { VariableDto } from '../../variable-bundle/dto/variable.dto'; +import { SimpleVariableBundleDto } from '../../variable-bundle/dto/simple-variable-bundle.dto'; + +/** + * DTO for creating a coding job + */ +export class CreateCodingJobDto { + @ApiProperty({ + description: 'Name of the coding job', + example: 'Coding Job 1' + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'Description of the coding job', + example: 'This is a coding job for testing', + required: false + }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + description: 'Status of the coding job', + example: 'pending', + enum: ['pending', 'active', 'completed'], + default: 'pending' + }) + @IsEnum(['pending', 'active', 'completed']) + @IsOptional() + status?: string; + + @ApiProperty({ + description: 'IDs of coders assigned to the coding job', + type: [Number], + example: [1, 2, 3], + required: false + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + assignedCoders?: number[]; + + @ApiProperty({ + description: 'Variables assigned to the coding job', + type: [VariableDto], + required: false + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VariableDto) + @IsOptional() + variables?: VariableDto[]; + + @ApiProperty({ + description: 'IDs of variable bundles assigned to the coding job', + type: [Number], + example: [1, 2, 3], + required: false + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + variableBundleIds?: number[]; + + @ApiProperty({ + description: 'Variable bundles assigned to the coding job', + type: [SimpleVariableBundleDto], + required: false + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SimpleVariableBundleDto) + @IsOptional() + variableBundles?: SimpleVariableBundleDto[]; +} diff --git a/apps/backend/src/app/admin/coding-job/dto/update-coding-job.dto.ts b/apps/backend/src/app/admin/coding-job/dto/update-coding-job.dto.ts new file mode 100644 index 000000000..d513503d6 --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/update-coding-job.dto.ts @@ -0,0 +1,91 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsArray, + IsEnum, + IsNumber, + ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; + +// Import the DTOs from the variable-bundle module +import { VariableDto } from '../../variable-bundle/dto/variable.dto'; +import { SimpleVariableBundleDto } from '../../variable-bundle/dto/simple-variable-bundle.dto'; + +/** + * DTO for updating a coding job + */ +export class UpdateCodingJobDto { + @ApiProperty({ + description: 'Name of the coding job', + example: 'Coding Job 1', + required: false + }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ + description: 'Description of the coding job', + example: 'This is a coding job for testing', + required: false + }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + description: 'Status of the coding job', + example: 'pending', + enum: ['pending', 'active', 'completed'], + required: false + }) + @IsEnum(['pending', 'active', 'completed']) + @IsOptional() + status?: string; + + @ApiProperty({ + description: 'IDs of coders assigned to the coding job', + type: [Number], + example: [1, 2, 3], + required: false + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + assignedCoders?: number[]; + + @ApiProperty({ + description: 'Variables assigned to the coding job', + type: [VariableDto], + required: false + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VariableDto) + @IsOptional() + variables?: VariableDto[]; + + @ApiProperty({ + description: 'IDs of variable bundles assigned to the coding job', + type: [Number], + example: [1, 2, 3], + required: false + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + variableBundleIds?: number[]; + + @ApiProperty({ + description: 'Variable bundles assigned to the coding job', + type: [SimpleVariableBundleDto], + required: false + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SimpleVariableBundleDto) + @IsOptional() + variableBundles?: SimpleVariableBundleDto[]; +} diff --git a/apps/backend/src/app/admin/variable-bundle/dto/simple-variable-bundle.dto.ts b/apps/backend/src/app/admin/variable-bundle/dto/simple-variable-bundle.dto.ts new file mode 100644 index 000000000..188b1e5d4 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/dto/simple-variable-bundle.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsNumber, + IsOptional, + IsString, + ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { VariableDto } from './variable.dto'; + +/** + * A simplified DTO for variable bundles + * Used for receiving variable bundles in requests + */ +export class SimpleVariableBundleDto { + @ApiProperty({ + description: 'The ID of the variable bundle', + example: 1, + required: false + }) + @IsNumber() + @IsOptional() + id?: number; + + @ApiProperty({ + description: 'The name of the variable bundle', + example: 'Mathematical Skills', + required: false + }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ + description: 'The description of the variable bundle', + example: 'Variables for assessing mathematical skills', + required: false + }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + description: 'The variables in the bundle', + type: [VariableDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VariableDto) + variables: VariableDto[]; +} diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index 104bcbc2a..d7d7c51b7 100755 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -8,13 +8,14 @@ import { AdminModule } from './admin/admin.module'; import { JobQueueModule } from './job-queue/job-queue.module'; import { HealthModule } from './health/health.module'; import { CacheModule } from './cache/cache.module'; +import { WsgAdminModule } from './wsg-admin/wsg-admin.module'; @Module({ imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.dev', cache: true - }), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule, CacheModule], + }), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule, CacheModule, WsgAdminModule], controllers: [AppController] }) export class AppModule {} diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index a07e63a1a..30960c1a6 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -48,6 +48,11 @@ import { Setting } from './entities/setting.entity'; import { ReplayStatistics } from './entities/replay-statistics.entity'; import { ReplayStatisticsService } from './services/replay-statistics.service'; import { VariableBundle } from './entities/variable-bundle.entity'; +import { CodingJob } from './entities/coding-job.entity'; +import { CodingJobCoder } from './entities/coding-job-coder.entity'; +import { CodingJobVariable } from './entities/coding-job-variable.entity'; +import { CodingJobVariableBundle } from './entities/coding-job-variable-bundle.entity'; +import { CodingJobService } from './services/coding-job.service'; // eslint-disable-next-line import/no-cycle import { JobQueueModule } from '../job-queue/job-queue.module'; // eslint-disable-next-line import/no-cycle @@ -83,7 +88,8 @@ import { CacheModule } from '../cache/cache.module'; password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), entities: [BookletInfo, Booklet, Session, BookletLog, Unit, UnitLog, UnitLastState, ResponseEntity, - User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics, VariableBundle + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics, VariableBundle, + CodingJob, CodingJobCoder, CodingJobVariable, CodingJobVariableBundle ], synchronize: false }), @@ -115,7 +121,11 @@ import { CacheModule } from '../cache/cache.module'; ValidationTask, Setting, ReplayStatistics, - VariableBundle + VariableBundle, + CodingJob, + CodingJobCoder, + CodingJobVariable, + CodingJobVariableBundle ]) ], providers: [ @@ -138,7 +148,8 @@ import { CacheModule } from '../cache/cache.module'; VariableAnalysisService, JobService, ValidationTaskService, - ReplayStatisticsService + ReplayStatisticsService, + CodingJobService ], exports: [ User, @@ -167,7 +178,8 @@ import { CacheModule } from '../cache/cache.module'; VariableAnalysisService, JobService, ValidationTaskService, - ReplayStatisticsService + ReplayStatisticsService, + CodingJobService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/coding-job-coder.entity.ts b/apps/backend/src/app/database/entities/coding-job-coder.entity.ts new file mode 100644 index 000000000..c9603edcb --- /dev/null +++ b/apps/backend/src/app/database/entities/coding-job-coder.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn +} from 'typeorm'; +import { CodingJob } from './coding-job.entity'; + +/** + * Entity for coding job coders (relation between coding jobs and users) + */ +@Entity({ name: 'coding_job_coder' }) +export class CodingJobCoder { + @PrimaryGeneratedColumn() + id: number; + + @Column() + coding_job_id: number; + + @Column() + user_id: number; + + @CreateDateColumn() + created_at: Date; + + @ManyToOne(() => CodingJob) + @JoinColumn({ name: 'coding_job_id' }) + coding_job: CodingJob; +} diff --git a/apps/backend/src/app/database/entities/coding-job-variable-bundle.entity.ts b/apps/backend/src/app/database/entities/coding-job-variable-bundle.entity.ts new file mode 100644 index 000000000..b4dde7f5d --- /dev/null +++ b/apps/backend/src/app/database/entities/coding-job-variable-bundle.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn +} from 'typeorm'; +import { CodingJob } from './coding-job.entity'; +import { VariableBundle } from './variable-bundle.entity'; + +/** + * Entity for coding job variable bundles (relation between coding jobs and variable bundles) + */ +@Entity({ name: 'coding_job_variable_bundle' }) +export class CodingJobVariableBundle { + @PrimaryGeneratedColumn() + id: number; + + @Column() + coding_job_id: number; + + @Column() + variable_bundle_id: number; + + @CreateDateColumn() + created_at: Date; + + @ManyToOne(() => CodingJob) + @JoinColumn({ name: 'coding_job_id' }) + coding_job: CodingJob; + + @ManyToOne(() => VariableBundle) + @JoinColumn({ name: 'variable_bundle_id' }) + variable_bundle: VariableBundle; +} diff --git a/apps/backend/src/app/database/entities/coding-job-variable.entity.ts b/apps/backend/src/app/database/entities/coding-job-variable.entity.ts new file mode 100644 index 000000000..b4e431330 --- /dev/null +++ b/apps/backend/src/app/database/entities/coding-job-variable.entity.ts @@ -0,0 +1,34 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn +} from 'typeorm'; +import { CodingJob } from './coding-job.entity'; + +/** + * Entity for coding job variables (relation between coding jobs and variables) + */ +@Entity({ name: 'coding_job_variable' }) +export class CodingJobVariable { + @PrimaryGeneratedColumn() + id: number; + + @Column() + coding_job_id: number; + + @Column() + unit_name: string; + + @Column() + variable_id: string; + + @CreateDateColumn() + created_at: Date; + + @ManyToOne(() => CodingJob) + @JoinColumn({ name: 'coding_job_id' }) + coding_job: CodingJob; +} diff --git a/apps/backend/src/app/database/entities/coding-job.entity.ts b/apps/backend/src/app/database/entities/coding-job.entity.ts new file mode 100644 index 000000000..5b469251b --- /dev/null +++ b/apps/backend/src/app/database/entities/coding-job.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn +} from 'typeorm'; + +/** + * Entity for coding jobs + * A coding job is a collection of variables and variable bundles assigned to coders + */ +@Entity({ name: 'coding_job' }) +export class CodingJob { + @PrimaryGeneratedColumn() + id: number; + + @Column() + workspace_id: number; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + /** + * Status of the job: 'pending', 'active', 'completed' + */ + @Column({ default: 'pending' }) + status: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/apps/backend/src/app/database/services/coding-job.service.ts b/apps/backend/src/app/database/services/coding-job.service.ts new file mode 100644 index 000000000..6af718688 --- /dev/null +++ b/apps/backend/src/app/database/services/coding-job.service.ts @@ -0,0 +1,339 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { CodingJob } from '../entities/coding-job.entity'; +import { CodingJobCoder } from '../entities/coding-job-coder.entity'; +import { CodingJobVariable } from '../entities/coding-job-variable.entity'; +import { CodingJobVariableBundle } from '../entities/coding-job-variable-bundle.entity'; +import { CreateCodingJobDto } from '../../admin/coding-job/dto/create-coding-job.dto'; +import { UpdateCodingJobDto } from '../../admin/coding-job/dto/update-coding-job.dto'; +import { VariableBundle } from '../entities/variable-bundle.entity'; + +/** + * Service for managing coding jobs + */ +@Injectable() +export class CodingJobService { + constructor( + @InjectRepository(CodingJob) + private codingJobRepository: Repository, + @InjectRepository(CodingJobCoder) + private codingJobCoderRepository: Repository, + @InjectRepository(CodingJobVariable) + private codingJobVariableRepository: Repository, + @InjectRepository(CodingJobVariableBundle) + private codingJobVariableBundleRepository: Repository, + @InjectRepository(VariableBundle) + private variableBundleRepository: Repository + ) {} + + /** + * Get coding jobs for a workspace with pagination + * @param workspaceId The ID of the workspace + * @param page The page number (1-based) + * @param limit The number of items per page + * @returns Paginated coding jobs with metadata + */ + async getCodingJobs( + workspaceId: number, + page: number = 1, + limit: number = 10 + ): Promise<{ data: CodingJob[]; total: number; page: number; limit: number }> { + const validPage = page > 0 ? page : 1; + const validLimit = limit > 0 ? limit : 10; + + const skip = (validPage - 1) * validLimit; + + const total = await this.codingJobRepository.count({ + where: { workspace_id: workspaceId } + }); + + const data = await this.codingJobRepository.find({ + where: { workspace_id: workspaceId }, + order: { created_at: 'DESC' }, + skip, + take: validLimit + }); + + return { + data, + total, + page: validPage, + limit: validLimit + }; + } + + /** + * Get a coding job by ID + * @param id The ID of the coding job + * @param workspaceId Optional workspace ID to filter by + * @returns The coding job with its relations + * @throws NotFoundException if the coding job is not found + */ + async getCodingJob(id: number, workspaceId?: number): Promise<{ + codingJob: CodingJob; + assignedCoders: number[]; + variables: { unitName: string; variableId: string }[]; + variableBundles: VariableBundle[]; + }> { + const whereClause: { id: number; workspace_id?: number } = { id }; + + if (workspaceId !== undefined) { + whereClause.workspace_id = workspaceId; + } + + const codingJob = await this.codingJobRepository.findOne({ where: whereClause }); + if (!codingJob) { + if (workspaceId !== undefined) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } else { + throw new NotFoundException(`Coding job with ID ${id} not found`); + } + } + + // Get assigned coders + const coders = await this.codingJobCoderRepository.find({ + where: { coding_job_id: id } + }); + const assignedCoders = coders.map(coder => coder.user_id); + + // Get variables + const codingJobVariables = await this.codingJobVariableRepository.find({ + where: { coding_job_id: id } + }); + const variables = codingJobVariables.map(variable => ({ + unitName: variable.unit_name, + variableId: variable.variable_id + })); + + // Get variable bundles + const codingJobVariableBundles = await this.codingJobVariableBundleRepository.find({ + where: { coding_job_id: id } + }); + const variableBundleIds = codingJobVariableBundles.map(bundle => bundle.variable_bundle_id); + const variableBundles = await this.variableBundleRepository.find({ + where: { id: In(variableBundleIds) } + }); + + return { + codingJob, + assignedCoders, + variables, + variableBundles + }; + } + + /** + * Create a new coding job + * @param workspaceId The ID of the workspace + * @param createCodingJobDto The coding job data + * @returns The created coding job + */ + async createCodingJob( + workspaceId: number, + createCodingJobDto: CreateCodingJobDto + ): Promise { + // Create the coding job + const codingJob = this.codingJobRepository.create({ + workspace_id: workspaceId, + name: createCodingJobDto.name, + description: createCodingJobDto.description, + status: createCodingJobDto.status || 'pending' + }); + + // Save the coding job + const savedCodingJob = await this.codingJobRepository.save(codingJob); + + // Assign coders if provided + if (createCodingJobDto.assignedCoders && createCodingJobDto.assignedCoders.length > 0) { + await this.assignCoders(savedCodingJob.id, createCodingJobDto.assignedCoders); + } + + // Assign variables if provided + if (createCodingJobDto.variables && createCodingJobDto.variables.length > 0) { + await this.assignVariables(savedCodingJob.id, createCodingJobDto.variables); + } + + // Assign variable bundles if provided + if (createCodingJobDto.variableBundleIds && createCodingJobDto.variableBundleIds.length > 0) { + await this.assignVariableBundles(savedCodingJob.id, createCodingJobDto.variableBundleIds); + } else if (createCodingJobDto.variableBundles && createCodingJobDto.variableBundles.length > 0) { + // Handle variable bundles without IDs by using their variables directly + if (createCodingJobDto.variableBundles[0].id) { + // Extract IDs from variableBundles if they have IDs + const bundleIds = createCodingJobDto.variableBundles + .filter(bundle => bundle.id) + .map(bundle => bundle.id); + + if (bundleIds.length > 0) { + await this.assignVariableBundles(savedCodingJob.id, bundleIds); + } + } else { + // Otherwise, extract variables and assign them directly + const variables = createCodingJobDto.variableBundles.flatMap(bundle => bundle.variables || []); + if (variables.length > 0) { + await this.assignVariables(savedCodingJob.id, variables); + } + } + } + + return savedCodingJob; + } + + /** + * Update a coding job + * @param id The ID of the coding job + * @param workspaceId The ID of the workspace + * @param updateCodingJobDto The coding job data to update + * @returns The updated coding job + * @throws NotFoundException if the coding job is not found + */ + async updateCodingJob( + id: number, + workspaceId: number, + updateCodingJobDto: UpdateCodingJobDto + ): Promise { + const codingJob = await this.getCodingJob(id, workspaceId); + + // Update the coding job + if (updateCodingJobDto.name !== undefined) { + codingJob.codingJob.name = updateCodingJobDto.name; + } + if (updateCodingJobDto.description !== undefined) { + codingJob.codingJob.description = updateCodingJobDto.description; + } + if (updateCodingJobDto.status !== undefined) { + codingJob.codingJob.status = updateCodingJobDto.status; + } + + // Save the coding job + const savedCodingJob = await this.codingJobRepository.save(codingJob.codingJob); + + // Update assigned coders if provided + if (updateCodingJobDto.assignedCoders !== undefined) { + // Remove existing coders + await this.codingJobCoderRepository.delete({ coding_job_id: id }); + // Assign new coders + if (updateCodingJobDto.assignedCoders.length > 0) { + await this.assignCoders(id, updateCodingJobDto.assignedCoders); + } + } + + // Update variables if provided + if (updateCodingJobDto.variables !== undefined) { + // Remove existing variables + await this.codingJobVariableRepository.delete({ coding_job_id: id }); + // Assign new variables + if (updateCodingJobDto.variables.length > 0) { + await this.assignVariables(id, updateCodingJobDto.variables); + } + } + + // Update variable bundles if provided + if (updateCodingJobDto.variableBundleIds !== undefined) { + // Remove existing variable bundles + await this.codingJobVariableBundleRepository.delete({ coding_job_id: id }); + // Assign new variable bundles + if (updateCodingJobDto.variableBundleIds.length > 0) { + await this.assignVariableBundles(id, updateCodingJobDto.variableBundleIds); + } + } else if (updateCodingJobDto.variableBundles !== undefined) { + // Remove existing variable bundles + await this.codingJobVariableBundleRepository.delete({ coding_job_id: id }); + + // Handle variable bundles without IDs by using their variables directly + if (updateCodingJobDto.variableBundles.length > 0) { + // If the first bundle has an ID, use the IDs approach + if (updateCodingJobDto.variableBundles[0].id) { + const bundleIds = updateCodingJobDto.variableBundles + .filter(bundle => bundle.id) + .map(bundle => bundle.id); + + if (bundleIds.length > 0) { + await this.assignVariableBundles(id, bundleIds); + } + } else { + // Otherwise, extract variables and assign them directly + const variables = updateCodingJobDto.variableBundles.flatMap(bundle => bundle.variables || []); + if (variables.length > 0) { + await this.assignVariables(id, variables); + } + } + } + } + + return savedCodingJob; + } + + /** + * Delete a coding job + * @param id The ID of the coding job + * @param workspaceId The ID of the workspace + * @returns Object with success flag + * @throws NotFoundException if the coding job is not found + */ + async deleteCodingJob(id: number, workspaceId: number): Promise<{ success: boolean }> { + const codingJob = await this.getCodingJob(id, workspaceId); + + // Delete the coding job (cascade will delete related entities) + await this.codingJobRepository.remove(codingJob.codingJob); + + return { success: true }; + } + + /** + * Assign coders to a coding job + * @param codingJobId The ID of the coding job + * @param userIds The IDs of the users to assign + * @returns The created coding job coder relations + */ + async assignCoders(codingJobId: number, userIds: number[]): Promise { + // Remove existing coders first + await this.codingJobCoderRepository.delete({ coding_job_id: codingJobId }); + + // Create new coder assignments + const coders = userIds.map(userId => this.codingJobCoderRepository.create({ + coding_job_id: codingJobId, + user_id: userId + })); + + return this.codingJobCoderRepository.save(coders); + } + + /** + * Assign variables to a coding job + * @param codingJobId The ID of the coding job + * @param variables The variables to assign + * @returns The created coding job variable relations + */ + private async assignVariables( + codingJobId: number, + variables: { unitName: string; variableId: string }[] + ): Promise { + const codingJobVariables = variables.map(variable => this.codingJobVariableRepository.create({ + coding_job_id: codingJobId, + unit_name: variable.unitName, + variable_id: variable.variableId + })); + + return this.codingJobVariableRepository.save(codingJobVariables); + } + + /** + * Assign variable bundles to a coding job + * @param codingJobId The ID of the coding job + * @param variableBundleIds The IDs of the variable bundles to assign + * @returns The created coding job variable bundle relations + */ + private async assignVariableBundles( + codingJobId: number, + variableBundleIds: number[] + ): Promise { + const variableBundles = variableBundleIds.map(variableBundleId => this.codingJobVariableBundleRepository.create({ + coding_job_id: codingJobId, + variable_bundle_id: variableBundleId + })); + + return this.codingJobVariableBundleRepository.save(variableBundles); + } +} diff --git a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts new file mode 100644 index 000000000..e3bc416ee --- /dev/null +++ b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts @@ -0,0 +1,284 @@ +import { + BadRequestException, + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + NotFoundException, + Param, + ParseIntPipe, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from '../../admin/workspace/workspace.guard'; +import { WorkspaceId } from '../../admin/workspace/workspace.decorator'; +import { CodingJobService } from '../../database/services/coding-job.service'; +import { CodingJobDto } from '../../admin/coding-job/dto/coding-job.dto'; +import { CreateCodingJobDto } from '../../admin/coding-job/dto/create-coding-job.dto'; +import { UpdateCodingJobDto } from '../../admin/coding-job/dto/update-coding-job.dto'; +import { VariableBundleDto } from '../../admin/variable-bundle/dto/variable-bundle.dto'; +import { VariableDto } from '../../admin/variable-bundle/dto/variable.dto'; + +@ApiTags('WSG Admin Coding Jobs') +@Controller('wsg-admin/workspace/:workspace_id/coding-job') +export class WsgCodingJobController { + constructor(private readonly codingJobService: CodingJobService) {} + + @Get() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all coding jobs', + description: 'Retrieves all coding jobs for a workspace with pagination' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'Unique identifier for the workspace' + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'List of coding jobs retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/CodingJobDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getCodingJobs( + @WorkspaceId() workspaceId: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise<{ data: CodingJobDto[]; total: number; page: number; limit: number }> { + try { + const result = await this.codingJobService.getCodingJobs(workspaceId, page, limit); + return { + data: result.data.map(job => CodingJobDto.fromEntity(job)), + total: result.total, + page: result.page, + limit: result.limit + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve coding jobs: ${error.message}`); + } + } + + @Get(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a coding job by ID', + description: 'Retrieves a coding job by ID' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully retrieved.', + type: CodingJobDto + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + async getCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number + ): Promise { + try { + const result = await this.codingJobService.getCodingJob(id, workspaceId); + const dto = CodingJobDto.fromEntity(result.codingJob); + dto.assigned_coders = result.assignedCoders; + dto.variables = result.variables.map(v => { + const variableDto = new VariableDto(); + variableDto.unitName = v.unitName; + variableDto.variableId = v.variableId; + return variableDto; + }); + dto.variable_bundles = result.variableBundles.map(vb => VariableBundleDto.fromEntity(vb)); + return dto; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve coding job: ${error.message}`); + } + } + + @Post() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new coding job', + description: 'Creates a new coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiCreatedResponse({ + description: 'The coding job has been successfully created.', + type: CodingJobDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async createCodingJob( + @WorkspaceId() workspaceId: number, + @Body() createCodingJobDto: CreateCodingJobDto + ): Promise { + try { + const codingJob = await this.codingJobService.createCodingJob( + workspaceId, + createCodingJobDto + ); + return CodingJobDto.fromEntity(codingJob); + } catch (error) { + throw new BadRequestException(`Failed to create coding job: ${error.message}`); + } + } + + @Put(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a coding job', + description: 'Updates a coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully updated.', + type: CodingJobDto + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async updateCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number, + @Body() updateCodingJobDto: UpdateCodingJobDto + ): Promise { + try { + const codingJob = await this.codingJobService.updateCodingJob( + id, + workspaceId, + updateCodingJobDto + ); + return CodingJobDto.fromEntity(codingJob); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to update coding job: ${error.message}`); + } + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a coding job', + description: 'Deletes a coding job' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the coding job' + }) + @ApiOkResponse({ + description: 'The coding job has been successfully deleted.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Coding job not found.' + }) + async deleteCodingJob( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number + ): Promise<{ success: boolean }> { + try { + return await this.codingJobService.deleteCodingJob(id, workspaceId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to delete coding job: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/wsg-admin/coding-job/coding-job.module.ts b/apps/backend/src/app/wsg-admin/coding-job/coding-job.module.ts new file mode 100644 index 000000000..6f62dfa9b --- /dev/null +++ b/apps/backend/src/app/wsg-admin/coding-job/coding-job.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WsgCodingJobController } from './coding-job.controller'; +import { CodingJobService } from '../../database/services/coding-job.service'; +import { CodingJob } from '../../database/entities/coding-job.entity'; +import { CodingJobCoder } from '../../database/entities/coding-job-coder.entity'; +import { CodingJobVariable } from '../../database/entities/coding-job-variable.entity'; +import { CodingJobVariableBundle } from '../../database/entities/coding-job-variable-bundle.entity'; +import { VariableBundle } from '../../database/entities/variable-bundle.entity'; +import { AuthModule } from '../../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CodingJob, + CodingJobCoder, + CodingJobVariable, + CodingJobVariableBundle, + VariableBundle + ]), + AuthModule + ], + controllers: [WsgCodingJobController], + providers: [CodingJobService], + exports: [CodingJobService] +}) +export class WsgCodingJobModule {} diff --git a/apps/backend/src/app/wsg-admin/wsg-admin.module.ts b/apps/backend/src/app/wsg-admin/wsg-admin.module.ts new file mode 100644 index 000000000..3f827a7b1 --- /dev/null +++ b/apps/backend/src/app/wsg-admin/wsg-admin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WsgCodingJobModule } from './coding-job/coding-job.module'; + +@Module({ + imports: [WsgCodingJobModule], + controllers: [], + providers: [], + exports: [] +}) +export class WsgAdminModule {} diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html index 33272f509..50de26f5e 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html @@ -34,46 +34,115 @@

Allgemeine Informationen

Variablenbündel

Wählen Sie die Variablen und Variablenbündel aus, die diesem Kodierjob zugeordnet werden sollen.

+ +
+
+

Ausgewählte Elemente

+
+
+ Variablen: + {{ selectedVariables.selected.length }} +
+
+ Variablenbündel: + {{ selectedVariableBundles.selected.length }} +
+
+ Kodierer: + {{ coders.length }} +
+
+ + @if (selectedVariables.selected.length > 0 || selectedVariableBundles.selected.length > 0) { + + + + Auswahldetails anzeigen + + + + @if (selectedVariables.selected.length > 0) { +
+
Ausgewählte Variablen
+
+ @for (variable of selectedVariables.selected.slice(0, 10); track variable.unitName + variable.variableId) { + + {{ variable.unitName }}: {{ variable.variableId }} + + } + @if (selectedVariables.selected.length > 10) { + +{{ selectedVariables.selected.length - 10 }} weitere + } +
+
+ } + + @if (selectedVariableBundles.selected.length > 0) { +
+
Ausgewählte Variablenbündel
+
+ @for (bundle of selectedVariableBundles.selected.slice(0, 10); track bundle.id) { + + {{ bundle.name }} ({{ getVariableCount(bundle) }} Variablen) + + } + @if (selectedVariableBundles.selected.length > 10) { + +{{ selectedVariableBundles.selected.length - 10 }} weitere + } +
+
+ } +
+ } +
+
+ - @if (isLoadingCoders) { -
- -

Lade Kodierer...

+
+
+ info +

+ Kodierer werden diesem Job über die Kodierer-Verwaltung zugewiesen. + Hier sehen Sie eine Übersicht der bereits zugewiesenen Kodierer. +

- } - @if (!isLoadingCoders && coders.length > 0) { -
- - - - - - - - - + @if (isLoadingCoders) { +
+ +

Lade Kodierer...

+
+ } + + @if (!isLoadingCoders && coders.length > 0) { +
+
@for (coder of coders; track coder.id) { -
- - - - +
+
+ person +
+
+

{{ coder.displayName || coder.name }}

+

{{ coder.name }}

+
ID: {{ coder.id }}
+
+
} - -
IDNameAnzeigename
{{ coder.id }}{{ coder.name }}{{ coder.displayName || coder.name }}
-
- } +
+
+ } - @if (!isLoadingCoders && coders.length === 0) { -
- person -

Keine Kodierer zugewiesen

-

Diesem Kodierjob sind noch keine Kodierer zugewiesen. Kodierer können über die Kodierer-Verwaltung zugewiesen werden.

-
- } + @if (!isLoadingCoders && coders.length === 0) { +
+ person +

Keine Kodierer zugewiesen

+

Diesem Kodierjob sind noch keine Kodierer zugewiesen. Kodierer können über die Kodierer-Verwaltung zugewiesen werden.

+
+ } +
@@ -116,63 +185,52 @@

Keine Kodierer zugewiesen

} - @if (!isLoadingVariableAnalysis && variableBundles.length > 0) { -
- - - - - - - - - - - - - - - - - - - - - - -
- - - - - - Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}
- - - + @if (!isLoadingVariableAnalysis && variables.length > 0) { + +
+ +
+ {{ selectedVariables.selected.length }} von {{ dataSource.filteredData.length }} Variablen ausgewählt +
-
-

{{ selectedVariableBundles.selected.length }} Variablen ausgewählt

+ +
+
+ @for (variable of dataSource.filteredData; track variable.unitName + variable.variableId) { +
+
+ + +
+
{{ variable.variableId }}
+
Aufgabe: {{ variable.unitName }}
+
+
+
+ } +
+ + + } - @if (!isLoadingVariableAnalysis && variableBundles.length === 0) { + @if (!isLoadingVariableAnalysis && variables.length === 0) {
analytics

Keine Variablen verfügbar

@@ -212,50 +270,48 @@

Keine Variablen verfügbar

} @if (!isLoadingBundles && variableBundles.length > 0) { -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - Name{{ element.name }}Beschreibung{{ element.description }}Anzahl Variablen{{ getVariableCount(element) }}
+
+
+ @for (bundle of bundlesDataSource.filteredData; track bundle.id) { +
+
+ + +

{{ bundle.name }}

+
+
+

{{ bundle.description || 'Keine Beschreibung' }}

+
+ {{ getVariableCount(bundle) }} Variablen +
+
+ @if (getVariableCount(bundle) > 0) { +
Enthaltene Variablen:
+
+ @for (variable of bundle.variables.slice(0, 3); track variable.unitName + variable.variableId) { +
+ {{ variable.unitName }}: {{ variable.variableId }} +
+ } + @if (getVariableCount(bundle) > 3) { +
+ +{{ getVariableCount(bundle) - 3 }} weitere +
+ } +
+ } + @if (getVariableCount(bundle) === 0) { +
Keine Variablen enthalten
+ } +
+
+
+ } +
diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss index edebfeb87..c7cfad9b8 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss @@ -144,6 +144,340 @@ } // Styles for the bundle preview +.selection-summary-container { + margin-bottom: 20px; + + .selection-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: #f8f9fa; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + + h4 { + margin-top: 0; + color: #333; + font-weight: 500; + } + + .selection-counts { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 16px; + + .count-item { + background-color: white; + border-radius: 4px; + padding: 8px 16px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + min-width: 120px; + + .count-label { + display: block; + font-size: 0.9rem; + color: rgba(0, 0, 0, 0.6); + } + + .count-value { + display: block; + font-size: 1.5rem; + font-weight: 500; + color: #1976d2; + } + } + } + + .selection-details { + margin-top: 8px; + + .selection-section { + margin-bottom: 16px; + + h5 { + margin-bottom: 8px; + font-weight: 500; + } + + .chip-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + + .selection-chip { + background-color: #e0e0e0; + border-radius: 16px; + padding: 4px 12px; + font-size: 0.9rem; + } + + .more-chip { + background-color: #bbdefb; + border-radius: 16px; + padding: 4px 12px; + font-size: 0.9rem; + } + } + } + } + } +} + +.quick-selection-tools { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding: 8px 16px; + background-color: #f5f5f5; + border-radius: 4px; + + .select-all-button { + display: flex; + align-items: center; + gap: 8px; + } + + .selection-count { + font-weight: 500; + color: #1976d2; + } +} + +.coders-tab-content { + padding: 16px 0; + + .coders-info-panel { + display: flex; + align-items: flex-start; + background-color: #e3f2fd; + border-radius: 4px; + padding: 16px; + margin-bottom: 20px; + + .info-icon { + color: #1976d2; + margin-right: 12px; + font-size: 24px; + } + + .info-text { + margin: 0; + color: rgba(0, 0, 0, 0.7); + line-height: 1.5; + } + } +} + +.coders-grid-container { + margin-bottom: 20px; + + .coders-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; + + .coder-card { + display: flex; + align-items: center; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + + .coder-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + border-radius: 50%; + background-color: #bbdefb; + margin-right: 16px; + + .avatar-icon { + color: #1976d2; + font-size: 30px; + width: 30px; + height: 30px; + } + } + + .coder-details { + flex: 1; + + .coder-name { + margin: 0 0 4px 0; + font-size: 1.1rem; + font-weight: 500; + } + + .coder-username { + margin: 0 0 4px 0; + font-size: 0.9rem; + color: rgba(0, 0, 0, 0.6); + } + + .coder-id { + font-size: 0.8rem; + color: rgba(0, 0, 0, 0.5); + background-color: #f5f5f5; + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + } + } + } + } +} + +.variables-grid-container { + margin-bottom: 20px; + + .variables-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + + .variable-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 12px; + background-color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); + } + + &.selected { + border-color: #1976d2; + background-color: #e3f2fd; + } + + .variable-card-header { + display: flex; + align-items: center; + + .variable-identifiers { + margin-left: 8px; + + .variable-id { + font-weight: 500; + font-size: 1rem; + } + + .unit-name { + font-size: 0.85rem; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; + } + } + } + } + } +} + +.bundles-grid-container { + margin-bottom: 20px; + + .bundles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + + .bundle-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + } + + &.selected { + border-color: #1976d2; + background-color: #e3f2fd; + } + + .bundle-card-header { + display: flex; + align-items: center; + margin-bottom: 12px; + + .bundle-name { + margin: 0 0 0 8px; + font-size: 1.1rem; + font-weight: 500; + } + } + + .bundle-card-content { + .bundle-description { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 12px; + font-size: 0.9rem; + min-height: 40px; + } + + .bundle-stats { + margin-bottom: 12px; + + .variable-count { + background-color: #e0e0e0; + border-radius: 16px; + padding: 4px 12px; + font-size: 0.9rem; + font-weight: 500; + } + } + + .bundle-variables-preview { + background-color: #f5f5f5; + border-radius: 4px; + padding: 8px; + + .variables-preview-header { + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 8px; + } + + .variables-preview-list { + .variable-preview-item { + font-size: 0.85rem; + padding: 4px 0; + border-bottom: 1px dashed rgba(0, 0, 0, 0.1); + } + + .variable-preview-more { + font-size: 0.85rem; + color: #1976d2; + padding: 4px 0; + font-weight: 500; + } + } + + .variables-preview-empty { + font-size: 0.85rem; + color: rgba(0, 0, 0, 0.5); + font-style: italic; + } + } + } + } + } +} + .bundle-preview-container { margin-top: 24px; border: 1px solid rgba(0, 0, 0, 0.12); diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts index 1376ca11c..b7bfe60a7 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts @@ -20,6 +20,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDividerModule } from '@angular/material/divider'; import { MatTabsModule } from '@angular/material/tabs'; import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateModule } from '@ngx-translate/core'; import { SelectionModel } from '@angular/cdk/collections'; import { CodingJob, VariableBundle, Variable } from '../../models/coding-job.model'; @@ -66,9 +67,11 @@ export class CodingJobDialogComponent implements OnInit { private backendService = inject(BackendService); private appService = inject(AppService); private coderService = inject(CoderService); + private snackBar = inject(MatSnackBar); codingJobForm!: FormGroup; isLoading = false; + isSaving = false; // Variables variables: Variable[] = []; @@ -83,7 +86,6 @@ export class CodingJobDialogComponent implements OnInit { // Variable bundles variableBundles: VariableBundle[] = []; selectedVariableBundles = new SelectionModel(true, []); - bundlesDisplayedColumns: string[] = ['select', 'name', 'description', 'variableCount']; bundlesDataSource = new MatTableDataSource([]); isLoadingBundles = false; @@ -152,7 +154,12 @@ export class CodingJobDialogComponent implements OnInit { } } - loadVariableAnalysisItems(page: number = 1, limit: number = 10): void { + loadVariableAnalysisItems( + page: number = 1, + limit: number = 10, + unitNameFilter?: string, + variableIdFilter?: string + ): void { this.isLoadingVariableAnalysis = true; const workspaceId = this.appService.selectedWorkspaceId; @@ -165,8 +172,8 @@ export class CodingJobDialogComponent implements OnInit { workspaceId, page, limit, - this.unitNameFilter || undefined, - this.variableIdFilter || undefined + unitNameFilter || undefined, + variableIdFilter || undefined ).subscribe({ next: response => { // Convert variable analysis items to variable bundles @@ -233,11 +240,17 @@ export class CodingJobDialogComponent implements OnInit { } onPageChange(event: PageEvent): void { - this.loadVariableAnalysisItems(event.pageIndex + 1, event.pageSize); + // Preserve filters when changing pages + this.loadVariableAnalysisItems( + event.pageIndex + 1, + event.pageSize, + this.unitNameFilter || undefined, + this.variableIdFilter || undefined + ); } applyFilter(): void { - this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize, this.unitNameFilter, this.variableIdFilter); } applyBundleFilter(): void { @@ -266,22 +279,6 @@ export class CodingJobDialogComponent implements OnInit { return numSelected === numRows; } - /** Selects all bundles if they are not all selected; otherwise clear selection. */ - masterToggleBundle(): void { - if (this.isAllBundlesSelected()) { - this.selectedVariableBundles.clear(); - } else { - this.bundlesDataSource.data.forEach(row => this.selectedVariableBundles.select(row)); - } - } - - /** The label for the checkbox on the passed bundles row */ - bundleCheckboxLabel(row?: VariableBundle): string { - if (!row) { - return `${this.isAllBundlesSelected() ? 'deselect' : 'select'} all`; - } - return `${this.selectedVariableBundles.isSelected(row) ? 'deselect' : 'select'} row ${row.name}`; - } /** Gets the number of variables in a bundle */ getVariableCount(bundle: VariableBundle): number { @@ -290,33 +287,35 @@ export class CodingJobDialogComponent implements OnInit { /** Whether the number of selected elements matches the total number of rows. */ isAllSelected(): boolean { - const numSelected = this.selectedVariableBundles.selected.length; + const numSelected = this.selectedVariables.selected.length; const numRows = this.dataSource.data.length; - return numSelected === numRows; + return numSelected === numRows && numRows > 0; } /** Selects all rows if they are not all selected; otherwise clear selection. */ masterToggle(): void { if (this.isAllSelected()) { - this.selectedVariableBundles.clear(); + this.selectedVariables.clear(); } else { this.dataSource.data.forEach(row => this.selectedVariables.select(row)); } } - /** The label for the checkbox on the passed row */ - checkboxLabel(row?: Variable): string { - if (!row) { - return `${this.isAllSelected() ? 'deselect' : 'select'} all`; - } - return `${this.selectedVariables.isSelected(row) ? 'deselect' : 'select'} row ${row.unitName}`; - } onSubmit(): void { if (this.codingJobForm.invalid) { return; } + this.isSaving = true; + + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + this.snackBar.open('No workspace selected', 'Close', { duration: 3000 }); + this.isSaving = false; + return; + } + const codingJob: CodingJob = { id: this.data.codingJob?.id || 0, ...this.codingJobForm.value, @@ -327,7 +326,32 @@ export class CodingJobDialogComponent implements OnInit { variableBundles: this.selectedVariableBundles.selected }; - this.dialogRef.close(codingJob); + // If we're editing an existing coding job + if (this.data.isEdit && this.data.codingJob?.id) { + this.backendService.updateCodingJob(workspaceId, this.data.codingJob.id, codingJob).subscribe({ + next: updatedJob => { + this.isSaving = false; + this.snackBar.open('Coding job updated successfully', 'Close', { duration: 3000 }); + this.dialogRef.close(updatedJob); + }, + error: error => { + this.isSaving = false; + this.snackBar.open(`Error updating coding job: ${error.message}`, 'Close', { duration: 5000 }); + } + }); + } else { // If we're creating a new coding job + this.backendService.createCodingJob(workspaceId, codingJob).subscribe({ + next: createdJob => { + this.isSaving = false; + this.snackBar.open('Coding job created successfully', 'Close', { duration: 3000 }); + this.dialogRef.close(createdJob); + }, + error: error => { + this.isSaving = false; + this.snackBar.open(`Error creating coding job: ${error.message}`, 'Close', { duration: 5000 }); + } + }); + } } onCancel(): void { diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html index 305c5e327..5a2ee0b68 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html @@ -4,22 +4,21 @@ add Kodierjob erstellen - + edit Kodierjob bearbeiten - + delete Kodierjob(s) löschen - + play_arrow Kodierjob starten - - person_add - Kodierer zuweisen -
@if (isLoading) { @@ -33,7 +32,7 @@ - + @@ -79,6 +78,20 @@ + + Variablen + + {{getVariables(element)}} + + + + + Variablenbündel + + {{getVariableBundles(element)}} + + + Erstellt am diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss index 8c56cac58..464b05326 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss @@ -97,6 +97,46 @@ mat-cell { } } +.mat-column-variables { + flex: 0 0 180px; +} + +.variables-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: #1976d2; + } +} + +.mat-column-variableBundles { + flex: 0 0 180px; +} + +.variable-bundles-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: #1976d2; + } +} + +.mat-column-createdAt, +.mat-column-updatedAt { + flex: 0 0 160px; + justify-content: flex-start; +} + .mat-column-created_at, .mat-column-updated_at { flex: 0 0 150px; } diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts index e71828009..e7e2eec1c 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts @@ -1,5 +1,5 @@ import { - Component, OnInit, ViewChild, AfterViewInit, inject, Input + Component, OnInit, ViewChild, AfterViewInit, inject } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { MatSort, MatSortModule } from '@angular/material/sort'; @@ -21,11 +21,14 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatAnchor, MatButton } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { DatePipe, NgClass } from '@angular/common'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { AppService } from '../../../services/app.service'; import { BackendService } from '../../../services/backend.service'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; -import { CodingJob } from '../../models/coding-job.model'; +import { CodingJob, Variable, VariableBundle } from '../../models/coding-job.model'; import { CodingJobDialogComponent } from '../coding-job-dialog/coding-job-dialog.component'; +import { ConfirmDialogComponent } from '../../../shared/confirm-dialog/confirm-dialog.component'; import { Coder } from '../../models/coder.model'; import { CoderService } from '../../services/coder.service'; @@ -69,9 +72,10 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { // Cache for storing coder names by job ID private coderNamesByJobId = new Map(); - @Input() selectedCoder: Coder | null = null; + // Cache for storing job details (variables and variable bundles) + private jobDetailsCache = new Map(); - displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'assignedCoders', 'createdAt', 'updatedAt']; + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'assignedCoders', 'variables', 'variableBundles', 'createdAt', 'updatedAt']; dataSource = new MatTableDataSource([]); selection = new SelectionModel(true, []); isLoading = false; @@ -120,10 +124,115 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { loadCodingJobs(): void { this.isLoading = true; - setTimeout(() => { - this.dataSource.data = this.sampleData; + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { this.isLoading = false; - }, 500); + return; + } + + this.backendService.getCodingJobs(workspaceId).subscribe({ + next: response => { + // Convert string dates to Date objects + const processedData = response.data.map(job => ({ + ...job, + createdAt: job.createdAt ? new Date(job.createdAt) : new Date(), + updatedAt: job.updatedAt ? new Date(job.updatedAt) : new Date() + })); + + this.dataSource.data = processedData; + // Clear the cache when loading new data + this.jobDetailsCache.clear(); + this.isLoading = false; + + // Prefetch details for visible jobs + this.prefetchJobDetails(); + }, + error: error => { + console.error('Error loading coding jobs:', error); + this.snackBar.open('Fehler beim Laden der Kodierjobs', 'Schließen', { duration: 3000 }); + this.isLoading = false; + } + }); + } + + /** + * Prefetches details for visible jobs to improve user experience + */ + private prefetchJobDetails(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return; + } + + // Get the first few jobs to prefetch (limit to avoid too many requests) + const jobsToFetch = this.dataSource.data.slice(0, 5); + + // Fetch details for each job + jobsToFetch.forEach(job => { + this.fetchJobDetails(job.id); + }); + } + + /** + * Fetches detailed information for a coding job + * @param jobId The ID of the job to fetch details for + */ + private fetchJobDetails(jobId: number): void { + // Check if we already have the details in cache + if (this.jobDetailsCache.has(jobId)) { + return; + } + + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return; + } + + // Fetch the job details + this.backendService.getCodingJob(workspaceId, jobId) + .pipe( + catchError(error => { + console.error(`Error fetching details for job ${jobId}:`, error); + return of(null); + }) + ) + .subscribe(job => { + if (job) { + // Convert dates to Date objects + if (job.createdAt) { + job.createdAt = new Date(job.createdAt); + } + if (job.updatedAt) { + job.updatedAt = new Date(job.updatedAt); + } + + // Convert dates in variable bundles if they exist + if (job.variableBundles) { + job.variableBundles = job.variableBundles.map(bundle => ({ + ...bundle, + createdAt: bundle.createdAt ? new Date(bundle.createdAt) : new Date(), + updatedAt: bundle.updatedAt ? new Date(bundle.updatedAt) : new Date() + })); + } + + // Store the details in cache + this.jobDetailsCache.set(jobId, { + variables: job.variables, + variableBundles: job.variableBundles + }); + + // Update the job in the data source to ensure dates are formatted correctly + const dataIndex = this.dataSource.data.findIndex(item => item.id === jobId); + if (dataIndex >= 0) { + const updatedData = [...this.dataSource.data]; + updatedData[dataIndex] = { + ...updatedData[dataIndex], + ...job + }; + this.dataSource.data = updatedData; + } + } + }); } applyFilter(filterValue: string): void { @@ -148,8 +257,149 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } } - selectRow(row: CodingJob): void { + selectRow(row: CodingJob, event?: MouseEvent): void { + // Prevent toggling selection when clicking on checkboxes + if (event && event.target instanceof Element) { + const target = event.target as Element; + if (target.tagName === 'MAT-CHECKBOX' || + target.classList.contains('mat-checkbox') || + target.closest('.mat-checkbox')) { + return; + } + } + this.selection.toggle(row); + + // Fetch job details when a row is selected + if (this.selection.isSelected(row)) { + this.fetchJobDetails(row.id); + } + } + + /** + * Gets the variables assigned to a coding job + * @param job The coding job + * @returns A formatted string of variable IDs or a loading message + */ + getVariables(job: CodingJob): string { + // Try to get from the job object first + if (job.variables && job.variables.length > 0) { + return this.formatVariables(job.variables); + } + + // Try to get from cache + const cachedDetails = this.jobDetailsCache.get(job.id); + if (cachedDetails && cachedDetails.variables && cachedDetails.variables.length > 0) { + return this.formatVariables(cachedDetails.variables); + } + + // If not in cache, fetch the details + this.fetchJobDetails(job.id); + return 'Wird geladen...'; + } + + /** + * Gets the variable bundles assigned to a coding job + * @param job The coding job + * @returns A formatted string of variable bundle names or a loading message + */ + getVariableBundles(job: CodingJob): string { + // Try to get from the job object first + if (job.variableBundles && job.variableBundles.length > 0) { + return this.formatVariableBundles(job.variableBundles); + } + + // Try to get from cache + const cachedDetails = this.jobDetailsCache.get(job.id); + if (cachedDetails && cachedDetails.variableBundles && cachedDetails.variableBundles.length > 0) { + return this.formatVariableBundles(cachedDetails.variableBundles); + } + + // If not in cache, fetch the details + this.fetchJobDetails(job.id); + return 'Wird geladen...'; + } + + /** + * Formats variables for display + * @param variables The variables to format + * @returns A formatted string of variable IDs + */ + private formatVariables(variables: Variable[]): string { + if (!variables || variables.length === 0) { + return 'Keine Variablen'; + } + + // Limit the number of variables shown to avoid overflow + const maxToShow = 3; + const variableIds = variables.map(v => v.variableId); + + if (variableIds.length <= maxToShow) { + return variableIds.join(', '); + } + + return `${variableIds.slice(0, maxToShow).join(', ')} +${variableIds.length - maxToShow} weitere`; + } + + /** + * Formats variable bundles for display + * @param bundles The variable bundles to format + * @returns A formatted string of variable bundle names + */ + private formatVariableBundles(bundles: VariableBundle[]): string { + if (!bundles || bundles.length === 0) { + return 'Keine Variablenbündel'; + } + + // Limit the number of bundles shown to avoid overflow + const maxToShow = 3; + const bundleNames = bundles.map(b => b.name); + + if (bundleNames.length <= maxToShow) { + return bundleNames.join(', '); + } + + return `${bundleNames.slice(0, maxToShow).join(', ')} +${bundleNames.length - maxToShow} weitere`; + } + + /** + * Gets the full list of variables for a tooltip + * @param job The coding job + * @returns A formatted string of all variable IDs + */ + getFullVariables(job: CodingJob): string { + // Try to get from the job object first + if (job.variables && job.variables.length > 0) { + return job.variables.map(v => v.variableId).join(', '); + } + + // Try to get from cache + const cachedDetails = this.jobDetailsCache.get(job.id); + if (cachedDetails && cachedDetails.variables && cachedDetails.variables.length > 0) { + return cachedDetails.variables.map(v => v.variableId).join(', '); + } + + return 'Keine Variablen'; + } + + /** + * Gets the full list of variable bundles for a tooltip + * @param job The coding job + * @returns A formatted string of all variable bundle names + */ + getFullVariableBundles(job: CodingJob): string { + // Try to get from the job object first + if (job.variableBundles && job.variableBundles.length > 0) { + return job.variableBundles.map(b => b.name).join(', '); + } + + // Try to get from cache + const cachedDetails = this.jobDetailsCache.get(job.id); + if (cachedDetails && cachedDetails.variableBundles && cachedDetails.variableBundles.length > 0) { + return cachedDetails.variableBundles.map(b => b.name).join(', '); + } + + return 'Keine Variablenbündel'; } createCodingJob(): void { @@ -163,9 +413,13 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { dialogRef.afterClosed().subscribe(result => { if (result) { const newId = this.getNextId(); + // Ensure dates are Date objects + const now = new Date(); const newCodingJob: CodingJob = { ...result, - id: newId + id: newId, + createdAt: now, + updatedAt: now }; const currentData = this.dataSource.data; this.dataSource.data = [...currentData, newCodingJob]; @@ -192,8 +446,24 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { const index = currentData.findIndex(job => job.id === result.id); if (index !== -1) { + // Preserve the original createdAt and ensure updatedAt is a Date object const updatedData = [...currentData]; - updatedData[index] = result; + const now = new Date(); + + // Handle createdAt date properly + let createdAtDate = now; + if (selectedJob.createdAt instanceof Date) { + createdAtDate = selectedJob.createdAt; + } else if (selectedJob.createdAt) { + createdAtDate = new Date(selectedJob.createdAt); + } + + updatedData[index] = { + ...result, + createdAt: createdAtDate, + updatedAt: now + }; + this.dataSource.data = updatedData; this.snackBar.open(`Kodierjob "${result.name}" wurde aktualisiert`, 'Schließen', { duration: 3000 }); @@ -216,7 +486,71 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { deleteCodingJobs(): void { if (this.selection.selected.length > 0) { const count = this.selection.selected.length; - this.snackBar.open(`Löschen von ${count} Kodierjob(s) noch nicht implementiert`, 'Schließen', { duration: 3000 }); + const jobNames = this.selection.selected.map(job => job.name).join(', '); + + // Confirm deletion using Angular Material dialog + const confirmMessage = count === 1 ? + `Möchten Sie den Kodierjob "${jobNames}" wirklich löschen?` : + `Möchten Sie ${count} Kodierjobs wirklich löschen?`; + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Löschen bestätigen', + message: confirmMessage, + confirmButtonText: 'Löschen', + cancelButtonText: 'Abbrechen' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + this.snackBar.open('Kein Workspace ausgewählt', 'Schließen', { duration: 3000 }); + return; + } + + // Track deletion progress + let successCount = 0; + let errorCount = 0; + + // Process each selected job + this.selection.selected.forEach(job => { + this.backendService.deleteCodingJob(workspaceId, job.id).subscribe({ + next: response => { + if (response.success) { + successCount += 1; + + // If all jobs have been processed, show success message and refresh the list + if (successCount + errorCount === this.selection.selected.length) { + const message = count === 1 ? + `Kodierjob "${jobNames}" wurde erfolgreich gelöscht` : + `${successCount} von ${count} Kodierjobs wurden erfolgreich gelöscht`; + + this.snackBar.open(message, 'Schließen', { duration: 3000 }); + this.selection.clear(); + this.loadCodingJobs(); + } + } else { + errorCount += 1; + this.snackBar.open(`Fehler beim Löschen von Kodierjob "${job.name}"`, 'Schließen', { duration: 3000 }); + } + }, + error: error => { + errorCount += 1; + console.error(`Error deleting coding job ${job.id}:`, error); + this.snackBar.open(`Fehler beim Löschen von Kodierjob "${job.name}"`, 'Schließen', { duration: 3000 }); + + // If all jobs have been processed, refresh the list + if (successCount + errorCount === this.selection.selected.length) { + this.loadCodingJobs(); + } + } + }); + }); + } + }); } } @@ -253,69 +587,6 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } } - /** - * Assigns the selected coding jobs to the selected coder - */ - assignToCoder(): void { - if (!this.selectedCoder) { - this.snackBar.open('Bitte wählen Sie zuerst einen Kodierer aus', 'Schließen', { duration: 3000 }); - return; - } - - if (this.selection.selected.length === 0) { - this.snackBar.open('Bitte wählen Sie mindestens einen Kodierjob aus', 'Schließen', { duration: 3000 }); - return; - } - - const coderId = this.selectedCoder.id; - const selectedJobs = this.selection.selected; - let assignedCount = 0; - - // Assign each selected job to the coder - selectedJobs.forEach(job => { - this.coderService.assignJob(coderId, job.id).subscribe({ - next: updatedCoder => { - if (updatedCoder) { - assignedCount += 1; - - // Update the job in the data source to reflect the assignment - const jobIndex = this.dataSource.data.findIndex(j => j.id === job.id); - if (jobIndex !== -1) { - const updatedJob = { ...this.dataSource.data[jobIndex] }; - - // Add the coder to the job's assignedCoders array if not already there - if (!updatedJob.assignedCoders.includes(coderId)) { - updatedJob.assignedCoders = [...updatedJob.assignedCoders, coderId]; - - // Update the data source - const updatedData = [...this.dataSource.data]; - updatedData[jobIndex] = updatedJob; - this.dataSource.data = updatedData; - } - } - - // Show success message when all jobs have been processed - if (assignedCount === selectedJobs.length) { - const jobText = selectedJobs.length === 1 ? 'Kodierjob' : 'Kodierjobs'; - this.snackBar.open( - `${selectedJobs.length} ${jobText} wurde(n) ${this.selectedCoder!.displayName} zugewiesen`, - 'Schließen', - { duration: 3000 } - ); - } - } - }, - error: () => { - this.snackBar.open( - `Fehler beim Zuweisen des Kodierjobs an ${this.selectedCoder!.displayName}`, - 'Schließen', - { duration: 3000 } - ); - } - }); - }); - } - /** * Gets the names of coders assigned to a job (truncated if too many) * @param job The coding job @@ -329,7 +600,7 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { if (!this.coderNamesByJobId.has(job.id)) { // Fetch coders assigned to this job this.coderService.getCodersByJobId(job.id).subscribe({ - next: coders => { + next: (coders: Coder[]) => { if (coders.length > 0) { // Store the formatted names for this job const coderNames = coders.map(coder => coder.displayName || coder.name).join(', '); diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html index 3e8173887..100491fb5 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html @@ -16,26 +16,12 @@

Manuelle Kodierung planen

-

Verwalten Sie Kodierer und Kodierjobs für die manuelle Kodierung von Antworten.

+

Verwalten Sie Kodierjobs für die manuelle Kodierung von Antworten.


-
- - Kodierer auswählen - - - {{ coder.displayName || coder.name }} - - - -
-
-

Kodierer

- -

Kodierjobs

- +

Variablenbündel

diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts index c1f263029..728336ec9 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts @@ -1,58 +1,23 @@ -import { Component, OnInit, inject } from '@angular/core'; -import { NgFor } from '@angular/common'; +import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { MatAnchor, MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { MatFormField, MatLabel } from '@angular/material/form-field'; -import { MatSelect, MatOption, MatSelectChange } from '@angular/material/select'; -import { CoderListComponent } from '../coder-list/coder-list.component'; import { CodingJobsComponent } from '../coding-jobs/coding-jobs.component'; import { VariableBundleManagerComponent } from '../variable-bundle-manager/variable-bundle-manager.component'; -import { CoderService } from '../../services/coder.service'; -import { Coder } from '../../models/coder.model'; @Component({ selector: 'coding-box-coding-management-manual', templateUrl: './coding-management-manual.component.html', styleUrls: ['./coding-management-manual.component.scss'], imports: [ - NgFor, TranslateModule, - CoderListComponent, MatAnchor, CodingJobsComponent, MatIcon, MatButton, - MatFormField, - MatLabel, - MatSelect, - MatOption, VariableBundleManagerComponent ] }) -export class CodingManagementManualComponent implements OnInit { - private coderService = inject(CoderService); - - coders: Coder[] = []; - selectedCoder: Coder | null = null; - - ngOnInit(): void { - this.loadCoders(); - } - - loadCoders(): void { - this.coderService.getCoders().subscribe({ - next: coders => { - this.coders = coders; - }, - error: error => { - console.error('Error loading coders:', error); - } - }); - } - - onCoderSelected(event: MatSelectChange): void { - const coderId = event.value; - this.selectedCoder = this.coders.find(coder => coder.id === coderId) || null; - } +export class CodingManagementManualComponent { + // Component simplified to remove coder management functionality } diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html index 1be69c0c2..a346289d1 100644 --- a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html @@ -71,20 +71,21 @@

Verfügbare Variablen

Aufgaben-ID Filter - - Variablen-ID Filter - - + + + diff --git a/apps/frontend/src/app/shared/confirm-dialog/confirm-dialog.component.ts b/apps/frontend/src/app/shared/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 000000000..164ccfd1f --- /dev/null +++ b/apps/frontend/src/app/shared/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,36 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateModule } from '@ngx-translate/core'; + +export interface ConfirmDialogData { + title: string; + message: string; + confirmButtonText: string; + cancelButtonText: string; +} + +@Component({ + selector: 'coding-box-confirm-dialog', + templateUrl: './confirm-dialog.component.html', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + TranslateModule + ] +}) +export class ConfirmDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData + ) {} + + onCancel(): void { + this.dialogRef.close(false); + } + + onConfirm(): void { + this.dialogRef.close(true); + } +} diff --git a/apps/frontend/src/app/shared/search-filter/search-filter.component.html b/apps/frontend/src/app/shared/search-filter/search-filter.component.html index 46ed586be..b255cfebd 100755 --- a/apps/frontend/src/app/shared/search-filter/search-filter.component.html +++ b/apps/frontend/src/app/shared/search-filter/search-filter.component.html @@ -3,15 +3,14 @@ {{ title() }} + [placeholder]="'search-filter.enter-filter' | translate"> diff --git a/apps/frontend/src/app/shared/search-filter/search-filter.component.ts b/apps/frontend/src/app/shared/search-filter/search-filter.component.ts index a0fb18ce9..77b8ecff2 100755 --- a/apps/frontend/src/app/shared/search-filter/search-filter.component.ts +++ b/apps/frontend/src/app/shared/search-filter/search-filter.component.ts @@ -1,13 +1,24 @@ import { Component, input, - output + output, + OnInit, + OnDestroy, + ViewChild, + ElementRef } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIconButton } from '@angular/material/button'; import { MatInput } from '@angular/material/input'; import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; +import { + Subject, + fromEvent, + debounceTime, + distinctUntilChanged, + takeUntil +} from 'rxjs'; import { WrappedIconComponent } from '../wrapped-icon/wrapped-icon.component'; @Component({ @@ -25,8 +36,47 @@ import { WrappedIconComponent } from '../wrapped-icon/wrapped-icon.component'; TranslateModule ] }) -export class SearchFilterComponent { +export class SearchFilterComponent implements OnInit, OnDestroy { + @ViewChild('filterInput', { static: true }) filterInput!: ElementRef; + value: string = ''; readonly title = input.required(); + readonly initialValue = input(''); readonly valueChange = output(); + + // Debounce time in milliseconds + private readonly debounceTimeMs = 300; + private destroy$ = new Subject(); + + ngOnInit(): void { + // Set initial value if provided + const initialVal = this.initialValue(); + if (initialVal) { + this.value = initialVal; + this.filterInput.nativeElement.value = initialVal; + } + + // Set up debounced input event + fromEvent(this.filterInput.nativeElement, 'keyup') + .pipe( + debounceTime(this.debounceTimeMs), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.value = this.filterInput.nativeElement.value; + this.valueChange.emit(this.value); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + clearFilter(): void { + this.value = ''; + this.filterInput.nativeElement.value = ''; + this.valueChange.emit(this.value); + } } diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index aa1c9050a..9e6aacdef 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -13,4 +13,4 @@ CREATE TABLE "public"."variable_bundle" ( CREATE INDEX "idx_variable_bundle_workspace_id" ON "public"."variable_bundle" ("workspace_id"); --- rollback DROP TABLE IF EXISTS "public"."variable_bundle"; +-- rollback DROP TABLE IF EXISTS "public"."variable_bundle" CASCADE; diff --git a/database/changelog/coding-box.changelog-0.13.0.sql b/database/changelog/coding-box.changelog-0.13.0.sql new file mode 100644 index 000000000..f93e142ec --- /dev/null +++ b/database/changelog/coding-box.changelog-0.13.0.sql @@ -0,0 +1,60 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE "public"."coding_job" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" TEXT, + "status" VARCHAR(50) NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_coding_job_workspace_id" ON "public"."coding_job" ("workspace_id"); +CREATE INDEX "idx_coding_job_status" ON "public"."coding_job" ("status"); + +-- rollback DROP TABLE IF EXISTS "public"."coding_job"; + +-- changeset jurei733:2 +CREATE TABLE "public"."coding_job_coder" ( + "id" SERIAL PRIMARY KEY, + "coding_job_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "fk_coding_job_coder_coding_job" FOREIGN KEY ("coding_job_id") REFERENCES "public"."coding_job" ("id") ON DELETE CASCADE +); + +CREATE INDEX "idx_coding_job_coder_coding_job_id" ON "public"."coding_job_coder" ("coding_job_id"); +CREATE INDEX "idx_coding_job_coder_user_id" ON "public"."coding_job_coder" ("user_id"); + +-- rollback DROP TABLE IF EXISTS "public"."coding_job_coder"; + +-- changeset jurei733:3 +CREATE TABLE "public"."coding_job_variable" ( + "id" SERIAL PRIMARY KEY, + "coding_job_id" INTEGER NOT NULL, + "unit_name" VARCHAR(255) NOT NULL, + "variable_id" VARCHAR(255) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "fk_coding_job_variable_coding_job" FOREIGN KEY ("coding_job_id") REFERENCES "public"."coding_job" ("id") ON DELETE CASCADE +); + +CREATE INDEX "idx_coding_job_variable_coding_job_id" ON "public"."coding_job_variable" ("coding_job_id"); + +-- rollback DROP TABLE IF EXISTS "public"."coding_job_variable"; + +-- changeset jurei733:4 +CREATE TABLE "public"."coding_job_variable_bundle" ( + "id" SERIAL PRIMARY KEY, + "coding_job_id" INTEGER NOT NULL, + "variable_bundle_id" INTEGER NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "fk_coding_job_variable_bundle_coding_job" FOREIGN KEY ("coding_job_id") REFERENCES "public"."coding_job" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_coding_job_variable_bundle_variable_bundle" FOREIGN KEY ("variable_bundle_id") REFERENCES "public"."variable_bundle" ("id") ON DELETE CASCADE +); + +CREATE INDEX "idx_coding_job_variable_bundle_coding_job_id" ON "public"."coding_job_variable_bundle" ("coding_job_id"); +CREATE INDEX "idx_coding_job_variable_bundle_variable_bundle_id" ON "public"."coding_job_variable_bundle" ("variable_bundle_id"); + +-- rollback DROP TABLE IF EXISTS "public"."coding_job_variable_bundle"; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 0e663988d..e922aaf30 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -21,4 +21,5 @@ + From e4db92e8ab76c978fe217795444c5a6474c7c3e8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:14:42 +0200 Subject: [PATCH 2/8] Validate responses completeness --- api-dto/coding/expected-combination.dto.ts | 29 ++ ...alidate-coding-completeness-request.dto.ts | 11 + ...lidate-coding-completeness-response.dto.ts | 21 + api-dto/coding/validation-result.dto.ts | 18 + .../workspace/workspace-coding.controller.ts | 28 +- .../services/workspace-coding.service.ts | 70 +++ .../coding-management-manual.component.html | 80 +++ .../coding-management-manual.component.scss | 137 +++++ .../coding-management-manual.component.ts | 246 ++++++++- .../services/test-person-coding.service.ts | 36 ++ .../services/validation-state.service.ts | 98 ++++ package-lock.json | 479 +++++++++++++++++- package.json | 1 + 13 files changed, 1232 insertions(+), 22 deletions(-) create mode 100644 api-dto/coding/expected-combination.dto.ts create mode 100644 api-dto/coding/validate-coding-completeness-request.dto.ts create mode 100644 api-dto/coding/validate-coding-completeness-response.dto.ts create mode 100644 api-dto/coding/validation-result.dto.ts create mode 100644 apps/frontend/src/app/coding/services/validation-state.service.ts diff --git a/api-dto/coding/expected-combination.dto.ts b/api-dto/coding/expected-combination.dto.ts new file mode 100644 index 000000000..1bdbdd47d --- /dev/null +++ b/api-dto/coding/expected-combination.dto.ts @@ -0,0 +1,29 @@ +/** + * DTO for expected combinations of responses to be validated + */ +export class ExpectedCombinationDto { + /** + * The alias of the unit (unit_key) + */ + unit_key!: string; + + /** + * The login name of the person + */ + login_name!: string; + + /** + * The login code of the person + */ + login_code!: string; + + /** + * The name of the booklet (booklet_id) + */ + booklet_id!: string; + + /** + * The ID of the variable + */ + variable_id!: string; +} diff --git a/api-dto/coding/validate-coding-completeness-request.dto.ts b/api-dto/coding/validate-coding-completeness-request.dto.ts new file mode 100644 index 000000000..b932a1d29 --- /dev/null +++ b/api-dto/coding/validate-coding-completeness-request.dto.ts @@ -0,0 +1,11 @@ +import { ExpectedCombinationDto } from './expected-combination.dto'; + +/** + * DTO for validation request + */ +export class ValidateCodingCompletenessRequestDto { + /** + * The expected combinations to validate + */ + expectedCombinations!: ExpectedCombinationDto[]; +} diff --git a/api-dto/coding/validate-coding-completeness-response.dto.ts b/api-dto/coding/validate-coding-completeness-response.dto.ts new file mode 100644 index 000000000..b5bab12bb --- /dev/null +++ b/api-dto/coding/validate-coding-completeness-response.dto.ts @@ -0,0 +1,21 @@ +import { ValidationResultDto } from './validation-result.dto'; + +/** + * DTO for validation response + */ +export class ValidateCodingCompletenessResponseDto { + /** + * The validation results + */ + results!: ValidationResultDto[]; + + /** + * The total number of expected combinations + */ + total!: number; + + /** + * The number of missing responses + */ + missing!: number; +} diff --git a/api-dto/coding/validation-result.dto.ts b/api-dto/coding/validation-result.dto.ts new file mode 100644 index 000000000..99a2bc37d --- /dev/null +++ b/api-dto/coding/validation-result.dto.ts @@ -0,0 +1,18 @@ +import { ExpectedCombinationDto } from './expected-combination.dto'; + +/** + * DTO for validation result + */ +export class ValidationResultDto { + /** + * The expected combination + */ + combination!: ExpectedCombinationDto; + + /** + * The status of the validation + * MISSING: The response is missing + * EXISTS: The response exists + */ + status!: 'MISSING' | 'EXISTS'; +} diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 2fe7d9172..72d814346 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -14,6 +14,8 @@ import { WorkspaceId } from './workspace.decorator'; import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; import { PersonService } from '../../database/services/person.service'; import { VariableAnalysisItemDto } from '../../../../../../api-dto/coding/variable-analysis-item.dto'; +import { ValidateCodingCompletenessRequestDto } from '../../../../../../api-dto/coding/validate-coding-completeness-request.dto'; +import { ValidateCodingCompletenessResponseDto } from '../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; @ApiTags('Admin Workspace Coding') @Controller('admin/workspace') @@ -722,10 +724,6 @@ export class WorkspaceCodingController { const validPage = Math.max(1, page); const validLimit = Math.min(Math.max(1, limit), 500); // Set maximum limit to 500 - if (unitId || variableId || derivation) { - console.log(`Applying filters - unitId: ${unitId || 'none'}, variableId: ${variableId || 'none'}, derivation: ${derivation || 'none'}`); - } - return this.workspaceCodingService.getVariableAnalysis( workspace_id, authToken, @@ -737,4 +735,26 @@ export class WorkspaceCodingController { derivation ); } + + @Post(':workspace_id/coding/validate-completeness') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiBody({ + description: 'Expected combinations to validate', + type: ValidateCodingCompletenessRequestDto + }) + @ApiOkResponse({ + description: 'Validation results', + type: ValidateCodingCompletenessResponseDto + }) + async validateCodingCompleteness( + @WorkspaceId() workspace_id: number, + @Body() request: ValidateCodingCompletenessRequestDto + ): Promise { + return this.workspaceCodingService.validateCodingCompleteness( + workspace_id, + request.expectedCombinations + ); + } } 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 aed65d1e5..61530503a 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -17,6 +17,9 @@ import { CodebookGenerator } from '../../admin/code-book/codebook-generator.clas import { CodeBookContentSetting, UnitPropertiesForCodebook, Missing } from '../../admin/code-book/codebook.interfaces'; import { MissingsProfilesDto } from '../../../../../../api-dto/coding/missings-profiles.dto'; import { VariableAnalysisItemDto } from '../../../../../../api-dto/coding/variable-analysis-item.dto'; +import { ExpectedCombinationDto } from '../../../../../../api-dto/coding/expected-combination.dto'; +import { ValidationResultDto } from '../../../../../../api-dto/coding/validation-result.dto'; +import { ValidateCodingCompletenessResponseDto } from '../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; import { JobQueueService } from '../../job-queue/job-queue.service'; interface CodedResponse { @@ -2213,4 +2216,71 @@ export class WorkspaceCodingService { throw new Error('Could not retrieve variable analysis data. Please check the database connection or query.'); } } + + /** + * Validate completeness of coding responses + * @param workspaceId Workspace ID + * @param expectedCombinations Expected combinations from Excel + * @returns Validation results + */ + async validateCodingCompleteness( + workspaceId: number, + expectedCombinations: ExpectedCombinationDto[] + ): Promise { + try { + this.logger.log(`Validating coding completeness for workspace ${workspaceId} with ${expectedCombinations.length} expected combinations`); + const startTime = Date.now(); + + const results: ValidationResultDto[] = []; + let missingCount = 0; + + // Process in batches to avoid overwhelming the database + const batchSize = 100; + for (let i = 0; i < expectedCombinations.length; i += batchSize) { + const batch = expectedCombinations.slice(i, i + batchSize); + + // Create a query to check for each combination in the batch + for (const expected of batch) { + // Build a query to check if the response exists + const responseExists = await this.responseRepository + .createQueryBuilder('response') + .innerJoin('response.unit', 'unit') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .innerJoin('booklet.bookletinfo', 'bookletinfo') + .where('unit.alias = :unitKey', { unitKey: expected.unit_key }) + .andWhere('person.login = :loginName', { loginName: expected.login_name }) + .andWhere('person.code = :loginCode', { loginCode: expected.login_code }) + .andWhere('bookletinfo.name = :bookletId', { bookletId: expected.booklet_id }) + .andWhere('response.variableid = :variableId', { variableId: expected.variable_id }) + .andWhere('response.value IS NOT NULL') + .andWhere('response.value != :empty', { empty: '' }) + .getCount(); + + // Add the result + const status = responseExists > 0 ? 'EXISTS' : 'MISSING'; + if (status === 'MISSING') { + missingCount += 1; + } + + results.push({ + combination: expected, + status + }); + } + } + + const endTime = Date.now(); + this.logger.log(`Validation completed in ${endTime - startTime}ms. Found ${missingCount} missing responses out of ${expectedCombinations.length} expected combinations.`); + + return { + results, + total: expectedCombinations.length, + missing: missingCount + }; + } catch (error) { + this.logger.error(`Error validating coding completeness: ${error.message}`, error.stack); + throw new Error('Could not validate coding completeness. Please check the database connection or query.'); + } + } } diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html index 100491fb5..eaeac7907 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html @@ -10,6 +10,86 @@ school Schulung starten + + upload_file + Validierungs-Kodierliste + + +
+
+ + +
+
+

Validierung läuft

+

+ {{validationProgress.message}} +

+
+
+ +

{{validationProgress.progress}}%

+
+

Sie können andere Aufgaben erledigen, während die Validierung im Hintergrund läuft.

+
+
+ + +
+
+

Fehler bei der Validierung

+

+ {{validationProgress.message}} +

+

+ {{validationProgress.error}} +

+
+
+ +
+
+
+ + +
+
+

Validierungsergebnisse

+

+ Insgesamt: {{validationResults.total}} | + Fehlend: {{validationResults.missing}} +

+
+
+ + + + + + + + + + + + + + + + + + + + + +
Unit KeyLogin NameLogin CodeBooklet IDVariable IDStatus
{{result.combination.unit_key}}{{result.combination.login_name}}{{result.combination.login_code}}{{result.combination.booklet_id}}{{result.combination.variable_id}}{{result.status === 'MISSING' ? 'Fehlend' : 'Vorhanden'}}
+
diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss index 3903deda1..dd2163c79 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss @@ -139,6 +139,133 @@ } } +// Progress indicator styles +.validation-progress { + width: 100%; + margin-bottom: 24px; + + .progress-container { + margin: 20px 0; + position: relative; + + mat-progress-bar { + height: 8px; + border-radius: 4px; + overflow: hidden; + } + + .progress-text { + position: absolute; + right: 0; + top: -20px; + font-size: 14px; + font-weight: 500; + color: #1976d2; + margin: 0; + } + } + + .progress-note { + margin-top: 15px; + font-size: 13px; + color: #666; + font-style: italic; + background-color: rgba(25, 118, 210, 0.05); + padding: 10px; + border-radius: 4px; + border-left: 3px solid #1976d2; + } +} + +// Error message styles +.validation-error { + width: 100%; + margin-bottom: 24px; + + .error-card { + border-left: 4px solid #f44336; + + .section-title { + color: #f44336; + } + + .error-details { + background-color: rgba(244, 67, 54, 0.05); + padding: 12px; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + margin: 10px 0; + white-space: pre-wrap; + word-break: break-word; + } + + .error-actions { + display: flex; + justify-content: flex-end; + margin-top: 15px; + + button { + mat-icon { + margin-right: 8px; + } + } + } + } +} + +// Validation results styles +.validation-results { + width: 100%; + margin-bottom: 24px; + + .validation-content { + overflow-x: auto; + max-height: 500px; + overflow-y: auto; + } + + .results-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th, td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + + th { + background-color: #f5f5f5; + font-weight: 500; + color: #333; + position: sticky; + top: 0; + z-index: 10; + } + + tr { + &:hover { + background-color: rgba(25, 118, 210, 0.05); + } + + &.missing { + background-color: rgba(244, 67, 54, 0.05); + + &:hover { + background-color: rgba(244, 67, 54, 0.1); + } + + td:last-child { + color: #f44336; + font-weight: 500; + } + } + } + } +} + // Responsive styles @media (max-width: 768px) { .coding-container { @@ -152,6 +279,16 @@ .statistics-card { padding: 16px; } + + .validation-results { + .results-table { + font-size: 12px; + + th, td { + padding: 8px 10px; + } + } + } } @media (max-width: 576px) { diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts index 728336ec9..29594c2f1 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts @@ -1,9 +1,27 @@ -import { Component } from '@angular/core'; +import { + Component, + OnDestroy, + OnInit, + inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { MatAnchor, MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import * as ExcelJS from 'exceljs'; +import { Subject, takeUntil } from 'rxjs'; import { CodingJobsComponent } from '../coding-jobs/coding-jobs.component'; import { VariableBundleManagerComponent } from '../variable-bundle-manager/variable-bundle-manager.component'; +import { TestPersonCodingService } from '../../services/test-person-coding.service'; +import { ExpectedCombinationDto } from '../../../../../../../api-dto/coding/expected-combination.dto'; +import { ValidateCodingCompletenessResponseDto } from '../../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; +import { AppService } from '../../../services/app.service'; +import { + ValidationProgress, + ValidationStateService +} from '../../services/validation-state.service'; @Component({ selector: 'coding-box-coding-management-manual', @@ -15,9 +33,229 @@ import { VariableBundleManagerComponent } from '../variable-bundle-manager/varia CodingJobsComponent, MatIcon, MatButton, - VariableBundleManagerComponent + MatProgressBarModule, + VariableBundleManagerComponent, + CommonModule ] }) -export class CodingManagementManualComponent { - // Component simplified to remove coder management functionality +export class CodingManagementManualComponent implements OnInit, OnDestroy { + private testPersonCodingService = inject(TestPersonCodingService); + private appService = inject(AppService); + private snackBar = inject(MatSnackBar); + private validationStateService = inject(ValidationStateService); + private destroy$ = new Subject(); + + validationResults: ValidateCodingCompletenessResponseDto | null = null; + validationProgress: ValidationProgress | null = null; + isLoading = false; + + ngOnInit(): void { + // Subscribe to validation progress updates + this.validationStateService.validationProgress$ + .pipe(takeUntil(this.destroy$)) + .subscribe(progress => { + this.validationProgress = progress; + this.isLoading = progress.status === 'loading' || progress.status === 'processing'; + + if (progress.status === 'error') { + this.showError(progress.error || 'Fehler bei der Validierung'); + } + }); + + // Subscribe to validation results + this.validationStateService.validationResults$ + .pipe(takeUntil(this.destroy$)) + .subscribe(results => { + this.validationResults = results; + + if (results) { + this.showSuccess(`Validierung abgeschlossen. ${results.missing} von ${results.total} Kombinationen fehlen.`); + } + }); + + // Restore previous validation state if available + const currentResults = this.validationStateService.getValidationResults(); + if (currentResults) { + this.validationResults = currentResults; + } + + const currentProgress = this.validationStateService.getValidationProgress(); + this.validationProgress = currentProgress; + this.isLoading = currentProgress.status === 'loading' || currentProgress.status === 'processing'; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Handle file selection event + */ + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + + if (!input.files || input.files.length === 0) { + this.showError('Keine Datei ausgewählt'); + return; + } + + const file = input.files[0]; + if (!this.isExcelFile(file)) { + this.showError('Bitte wählen Sie eine Excel-Datei aus (.xlsx, .xls)'); + return; + } + + // Start validation process + this.validationStateService.startValidation(); + + // Process file in the background + setTimeout(() => { + this.readExcelFile(file); + }, 0); + } + + /** + * Check if the file is an Excel file + */ + private isExcelFile(file: File): boolean { + return file.name.endsWith('.xlsx') || file.name.endsWith('.xls'); + } + + /** + * Read Excel file and parse data using exceljs + */ + private readExcelFile(file: File): void { + const workbook = new ExcelJS.Workbook(); + const reader = new FileReader(); + + reader.onload = async (e: ProgressEvent) => { + try { + const buffer = e.target?.result as ArrayBuffer; + + // Update progress + this.validationStateService.updateProgress(10, 'Excel-Datei wird geladen...'); + + await workbook.xlsx.load(buffer); + this.validationStateService.updateProgress(30, 'Excel-Datei wird verarbeitet...'); + + // Get the first worksheet + const worksheet = workbook.getWorksheet(1); + if (!worksheet || worksheet.rowCount <= 1) { + this.validationStateService.setValidationError('Die Datei enthält keine gültigen Daten'); + return; + } + + // Extract headers from the first row + const headers: string[] = []; + worksheet.getRow(1).eachCell(cell => { + headers.push(cell.value?.toString() || ''); + }); + + this.validationStateService.updateProgress(40, 'Daten werden extrahiert...'); + + // Extract data from the remaining rows + const data: Record[] = []; + const totalRows = worksheet.rowCount - 1; + + for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) { + const row = worksheet.getRow(rowNumber); + const rowData: Record = {}; + + headers.forEach((header, index) => { + const cell = row.getCell(index + 1); + rowData[header.trim()] = cell.value?.toString() || ''; + }); + + data.push(rowData); + + // Update progress every 100 rows or at the end + if (rowNumber % 100 === 0 || rowNumber === worksheet.rowCount) { + const progress = 40 + Math.floor(((rowNumber - 2) / totalRows) * 20); + this.validationStateService.updateProgress( + progress, + `Daten werden extrahiert (${rowNumber - 1}/${totalRows})...` + ); + } + } + + if (data.length === 0) { + this.validationStateService.setValidationError('Die Datei enthält keine gültigen Daten'); + return; + } + + this.validationStateService.updateProgress(60, 'Daten werden für Validierung vorbereitet...'); + const expectedCombinations = this.mapToExpectedCombinations(data); + + this.validationStateService.updateProgress(70, 'Validierung wird durchgeführt...'); + this.validateCodingCompleteness(expectedCombinations); + } catch (error) { + this.validationStateService.setValidationError('Fehler beim Parsen der Excel-Datei'); + } + }; + + reader.onerror = () => { + this.validationStateService.setValidationError('Fehler beim Lesen der Datei'); + }; + + // Read the file as an ArrayBuffer for exceljs + reader.readAsArrayBuffer(file); + } + + /** + * Map parsed data to ExpectedCombinationDto[] + */ + private mapToExpectedCombinations(data: Record[]): ExpectedCombinationDto[] { + return data.map(item => ({ + unit_key: item.unit_key || '', + login_name: item.login_name || '', + login_code: item.login_code || '', + booklet_id: item.booklet_id || '', + variable_id: item.variable_id || '' + })); + } + + /** + * Validate coding completeness + */ + private validateCodingCompleteness(expectedCombinations: ExpectedCombinationDto[]): void { + const workspaceId = this.appService.selectedWorkspaceId; + + if (!workspaceId) { + this.validationStateService.setValidationError('Kein Arbeitsbereich ausgewählt'); + return; + } + + this.validationStateService.updateProgress(80, 'Validierung wird durchgeführt...'); + + this.testPersonCodingService.validateCodingCompleteness(workspaceId, expectedCombinations) + .subscribe({ + next: results => { + this.validationStateService.setValidationResults(results); + }, + error: () => { + this.validationStateService.setValidationError('Fehler bei der Validierung'); + } + }); + } + + /** + * Show error message + */ + private showError(message: string): void { + this.snackBar.open(message, 'Schließen', { + duration: 5000, + panelClass: ['error-snackbar'] + }); + } + + /** + * Show success message + */ + private showSuccess(message: string): void { + this.snackBar.open(message, 'Schließen', { + duration: 5000, + panelClass: ['success-snackbar'] + }); + } } diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index adb5c062b..5cb4be7b1 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -6,6 +6,13 @@ import { of } from 'rxjs'; import { SERVER_URL } from '../../injection-tokens'; +import { ExpectedCombinationDto } from '../../../../../../api-dto/coding/expected-combination.dto'; +import { + ValidateCodingCompletenessResponseDto +} from '../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; +import { + ValidateCodingCompletenessRequestDto +} from '../../../../../../api-dto/coding/validate-coding-completeness-request.dto'; export interface CodingStatistics { totalResponses: number; @@ -281,4 +288,33 @@ export class TestPersonCodingService { catchError(() => of({ success: false, message: `Failed to restart job ${jobId}` })) ); } + + /** + * Validate completeness of coding responses + * @param workspaceId Workspace ID + * @param expectedCombinations Expected combinations from Excel + * @returns Observable of validation results + */ + validateCodingCompleteness( + workspaceId: number, + expectedCombinations: ExpectedCombinationDto[] + ): Observable { + const request: ValidateCodingCompletenessRequestDto = { + expectedCombinations + }; + + return this.http + .post( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/validate-completeness`, + request, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ + results: [], + total: 0, + missing: 0 + })) + ); + } } diff --git a/apps/frontend/src/app/coding/services/validation-state.service.ts b/apps/frontend/src/app/coding/services/validation-state.service.ts new file mode 100644 index 000000000..ec9b39559 --- /dev/null +++ b/apps/frontend/src/app/coding/services/validation-state.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { ValidateCodingCompletenessResponseDto } from '../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; + +export interface ValidationProgress { + status: 'idle' | 'loading' | 'processing' | 'completed' | 'error'; + progress: number; // 0-100 + message: string; + error?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ValidationStateService { + private validationResultsSubject = new BehaviorSubject(null); + private validationProgressSubject = new BehaviorSubject({ + status: 'idle', + progress: 0, + message: '' + }); + + // Expose observables for components to subscribe to + validationResults$ = this.validationResultsSubject.asObservable(); + validationProgress$ = this.validationProgressSubject.asObservable(); + + /** + * Get current validation results + */ + getValidationResults(): ValidateCodingCompletenessResponseDto | null { + return this.validationResultsSubject.getValue(); + } + + /** + * Get current validation progress + */ + getValidationProgress(): ValidationProgress { + return this.validationProgressSubject.getValue(); + } + + /** + * Start validation process + */ + startValidation(): void { + this.validationProgressSubject.next({ + status: 'loading', + progress: 0, + message: 'Excel-Datei wird geladen und verarbeitet...' + }); + } + + /** + * Update validation progress + */ + updateProgress(progress: number, message: string): void { + this.validationProgressSubject.next({ + status: 'processing', + progress, + message + }); + } + + /** + * Set validation results and mark as completed + */ + setValidationResults(results: ValidateCodingCompletenessResponseDto): void { + this.validationResultsSubject.next(results); + this.validationProgressSubject.next({ + status: 'completed', + progress: 100, + message: `Validierung abgeschlossen. ${results.missing} von ${results.total} Kombinationen fehlen.` + }); + } + + /** + * Set validation error + */ + setValidationError(error: string): void { + this.validationProgressSubject.next({ + status: 'error', + progress: 0, + message: 'Fehler bei der Validierung', + error + }); + } + + /** + * Reset validation state + */ + resetValidation(): void { + this.validationResultsSubject.next(null); + this.validationProgressSubject.next({ + status: 'idle', + progress: 0, + message: '' + }); + } +} diff --git a/package-lock.json b/package-lock.json index 0d29bf552..820c78dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "docx": "^9.5.1", + "exceljs": "^4.4.0", "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", "ioredis": "^5.7.0", @@ -14811,6 +14812,59 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -14965,7 +15019,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, "license": "MIT" }, "node_modules/asynckit": { @@ -15345,6 +15398,15 @@ "node": ">=14.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -15354,6 +15416,19 @@ "node": "*" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -15398,6 +15473,12 @@ "node": ">= 6" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -15638,6 +15719,15 @@ "dev": true, "license": "MIT/X11" }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -15648,6 +15738,23 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bull": { "version": "4.16.5", "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", @@ -15938,6 +16045,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -16492,6 +16611,35 @@ "url": "https://www.patreon.com/infusion" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -16554,8 +16702,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "2.0.0", @@ -16786,6 +16933,45 @@ "node": ">= 6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -17900,6 +18086,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -18967,6 +19162,109 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/exceljs/node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/exceljs/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -19956,8 +20254,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -19973,6 +20270,35 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -20143,7 +20469,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -20182,7 +20507,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -20193,7 +20517,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -20974,7 +21297,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -22969,6 +23291,18 @@ "shell-quote": "^1.8.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/less": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/less/-/less-4.3.0.tgz", @@ -23126,6 +23460,12 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/listr2": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", @@ -23340,12 +23680,24 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "license": "MIT" }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, "node_modules/lodash.groupby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", @@ -23429,6 +23781,12 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -24692,7 +25050,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -25777,7 +26134,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -27102,6 +27458,27 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -29717,7 +30094,6 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, "engines": { "node": ">=14.14" } @@ -29801,6 +30177,15 @@ "node": ">=12" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-dump": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", @@ -30650,6 +31035,24 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -31575,8 +31978,7 @@ "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, "node_modules/xtend": { "version": "4.0.2", @@ -31682,6 +32084,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zone.js": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", diff --git a/package.json b/package.json index d2d179cef..d864b60d1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "docx": "^9.5.1", + "exceljs": "^4.4.0", "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", "ioredis": "^5.7.0", From 9e63535a4bca5c807c97d0c52ab288b8c3b4e656 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:09:08 +0200 Subject: [PATCH 3/8] Optimize coding statistics loading --- .../services/workspace-coding.service.ts | 52 ++++++++++--------- .../changelog/coding-box.changelog-0.13.0.sql | 29 +++++++++++ 2 files changed, 56 insertions(+), 25 deletions(-) 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 61530503a..ad8469d80 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -389,7 +389,7 @@ export class WorkspaceCodingService { // Rollback transaction on error await queryRunner.rollbackTransaction(); await queryRunner.release(); - throw error; + return false; } } @@ -430,8 +430,7 @@ export class WorkspaceCodingService { progressCallback?: (progress: number) => void ): Promise<{ allCodedResponses: CodedResponse[]; statistics: CodingStatistics }> { const allCodedResponses = []; - const estimatedResponseCount = allResponses.length; - allCodedResponses.length = estimatedResponseCount; + allCodedResponses.length = allResponses.length; let responseIndex = 0; const batchSize = 50; const emptyScheme = new Autocoder.CodingScheme({}); @@ -1264,25 +1263,24 @@ export class WorkspaceCodingService { }; try { - // Optimized query: get total count and status counts in a single query - const statusCountResults = await this.responseRepository.createQueryBuilder('response') - .innerJoin('response.unit', 'unit') - .innerJoin('unit.booklet', 'booklet') - .innerJoin('booklet.person', 'person') - .where('response.status = :status', { status: 'VALUE_CHANGED' }) - .andWhere('person.workspace_id = :workspace_id', { workspace_id }) - .andWhere('person.consider = :consider', { consider: true }) - .select('COALESCE(response.codedstatus, null)', 'statusValue') - .addSelect('COUNT(response.id)', 'count') - .groupBy('COALESCE(response.codedstatus, null)') - .getRawMany(); + const statusCountResults = await this.responseRepository.query(` + SELECT + response.codedstatus as "statusValue", + COUNT(response.id) as count + FROM response + INNER JOIN unit ON response.unitid = unit.id + INNER JOIN booklet ON unit.bookletid = booklet.id + INNER JOIN persons person ON booklet.personid = person.id + WHERE response.status = $1 + AND person.workspace_id = $2 + AND person.consider = $3 + GROUP BY response.codedstatus + `, ['VALUE_CHANGED', workspace_id, true]); - // Calculate total from the sum of all status counts let totalResponses = 0; statusCountResults.forEach(result => { const count = parseInt(result.count, 10); - // Ensure count is a valid number const validCount = Number.isNaN(count) ? 0 : count; statistics.statusCounts[result.statusValue] = validCount; totalResponses += validCount; @@ -1290,7 +1288,6 @@ export class WorkspaceCodingService { statistics.totalResponses = totalResponses; - // Cache the result this.statisticsCache.set(workspace_id, { data: statistics, timestamp: Date.now() @@ -1502,7 +1499,7 @@ export class WorkspaceCodingService { } } - async createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Promise { + async createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Promise { try { this.logger.log(`Creating missings profile for workspace ${workspaceId}`); @@ -1525,7 +1522,8 @@ export class WorkspaceCodingService { // Check if a profile with the same label already exists const existingProfile = profiles.find(p => p.label === profile.label); if (existingProfile) { - throw new Error(`A missings profile with label '${profile.label}' already exists`); + this.logger.error(`A missings profile with label '${profile.label}' already exists`); + return null; } // Add the new profile @@ -1541,7 +1539,7 @@ export class WorkspaceCodingService { } } - async updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Promise { + async updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Promise { try { this.logger.log(`Updating missings profile '${label}' for workspace ${workspaceId}`); @@ -1551,7 +1549,8 @@ export class WorkspaceCodingService { }); if (!setting) { - throw new Error('No missings profiles found'); + this.logger.error('No missings profiles found'); + return null; } let profiles: MissingsProfilesDto[] = []; @@ -1564,7 +1563,8 @@ export class WorkspaceCodingService { const index = profiles.findIndex(p => p.label === label); if (index === -1) { - throw new Error(`Missings profile with label '${label}' not found`); + this.logger.error(`Missings profile with label '${label}' not found`); + return null; } profiles[index] = profile; await this.saveMissingsProfiles(profiles); @@ -1901,6 +1901,9 @@ export class WorkspaceCodingService { * @param serverUrl Base server URL for replay links * @param page Page number for pagination (default: 1) * @param limit Number of items per page (default: 100) + * @param unitIdFilter Optional filter to search for specific unit IDs + * @param variableIdFilter Optional filter to search for specific variable IDs + * @param derivationFilter Optional filter to search for specific derivation values * @returns Paginated array of variable analysis items with all required information */ async getVariableAnalysis( @@ -2166,8 +2169,7 @@ export class WorkspaceCodingService { // Generate replay URL const variablePage = '0'; - const variableAnchor = variableId; - const replayUrl = `${serverUrl}/#/replay/${loginName}@${loginCode}@${bookletId}/${unitId}/${variablePage}/${variableAnchor}?auth=${authToken}`; + const replayUrl = `${serverUrl}/#/replay/${loginName}@${loginCode}@${bookletId}/${unitId}/${variablePage}/${variableId}?auth=${authToken}`; // Add to result result.push({ diff --git a/database/changelog/coding-box.changelog-0.13.0.sql b/database/changelog/coding-box.changelog-0.13.0.sql index f93e142ec..b97ebf7c6 100644 --- a/database/changelog/coding-box.changelog-0.13.0.sql +++ b/database/changelog/coding-box.changelog-0.13.0.sql @@ -58,3 +58,32 @@ CREATE INDEX "idx_coding_job_variable_bundle_coding_job_id" ON "public"."coding_ CREATE INDEX "idx_coding_job_variable_bundle_variable_bundle_id" ON "public"."coding_job_variable_bundle" ("variable_bundle_id"); -- rollback DROP TABLE IF EXISTS "public"."coding_job_variable_bundle"; + +-- changeset jurei733:5 + +-- Composite index for response table covering the main filters and join column +CREATE INDEX IF NOT EXISTS "idx_response_status_codedstatus_unitid" ON "public"."response" ("status", "codedstatus", "unitid") + WHERE status = 'VALUE_CHANGED'; + +-- Composite index for unit table covering the join columns +CREATE INDEX IF NOT EXISTS "idx_unit_id_bookletid" ON "public"."unit" ("id", "bookletid"); + +-- Composite index for booklet table covering the join columns +CREATE INDEX IF NOT EXISTS "idx_booklet_id_personid" ON "public"."booklet" ("id", "personid"); + +-- Composite index for persons table covering workspace filter and consider flag +CREATE INDEX IF NOT EXISTS "idx_persons_workspace_consider_id" ON "public"."persons" ("workspace_id", "consider", "id") + WHERE consider = true; + +-- rollback DROP INDEX IF EXISTS "idx_response_status_codedstatus_unitid"; +-- rollback DROP INDEX IF EXISTS "idx_unit_id_bookletid"; +-- rollback DROP INDEX IF EXISTS "idx_booklet_id_personid"; +-- rollback DROP INDEX IF EXISTS "idx_persons_workspace_consider_id"; + +-- changeset jurei733:6 +-- Additional optimization: Create covering index for the most common query pattern +-- This index includes all columns needed for the statistics query to avoid table lookups +CREATE INDEX IF NOT EXISTS "idx_response_statistics_covering" ON "public"."response" ("status", "unitid", "codedstatus", "id") + WHERE status = 'VALUE_CHANGED'; + +-- rollback DROP INDEX IF EXISTS "idx_response_statistics_covering"; From f0a8905f8b84bb715a0fde0136872c91a2bddea8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:47:33 +0200 Subject: [PATCH 4/8] Export the response completeness check as an Excel doc --- .../export-validation-results-request.dto.ts | 9 + ...alidate-coding-completeness-request.dto.ts | 12 +- ...lidate-coding-completeness-response.dto.ts | 36 ++- .../workspace/workspace-coding.controller.ts | 51 +++- apps/backend/src/app/cache/cache.service.ts | 156 ++++++++++ .../services/workspace-coding.service.ts | 285 +++++++++++++++++- .../coding-management-manual.component.html | 64 +++- .../coding-management-manual.component.ts | 152 +++++++++- .../services/test-person-coding.service.ts | 51 +++- 9 files changed, 776 insertions(+), 40 deletions(-) create mode 100644 api-dto/coding/export-validation-results-request.dto.ts diff --git a/api-dto/coding/export-validation-results-request.dto.ts b/api-dto/coding/export-validation-results-request.dto.ts new file mode 100644 index 000000000..7654eebe0 --- /dev/null +++ b/api-dto/coding/export-validation-results-request.dto.ts @@ -0,0 +1,9 @@ +/** + * DTO for exporting validation results using cache key + */ +export class ExportValidationResultsRequestDto { + /** + * Cache key from validation results to export complete data + */ + cacheKey!: string; +} diff --git a/api-dto/coding/validate-coding-completeness-request.dto.ts b/api-dto/coding/validate-coding-completeness-request.dto.ts index b932a1d29..9df6d61db 100644 --- a/api-dto/coding/validate-coding-completeness-request.dto.ts +++ b/api-dto/coding/validate-coding-completeness-request.dto.ts @@ -1,11 +1,21 @@ import { ExpectedCombinationDto } from './expected-combination.dto'; /** - * DTO for validation request + * DTO for validation request with pagination support */ export class ValidateCodingCompletenessRequestDto { /** * The expected combinations to validate */ expectedCombinations!: ExpectedCombinationDto[]; + + /** + * Page number (1-based). Defaults to 1 if not provided. + */ + page?: number; + + /** + * Number of items per page. Defaults to 50 if not provided. + */ + pageSize?: number; } diff --git a/api-dto/coding/validate-coding-completeness-response.dto.ts b/api-dto/coding/validate-coding-completeness-response.dto.ts index b5bab12bb..dcaffa3d8 100644 --- a/api-dto/coding/validate-coding-completeness-response.dto.ts +++ b/api-dto/coding/validate-coding-completeness-response.dto.ts @@ -1,11 +1,11 @@ import { ValidationResultDto } from './validation-result.dto'; /** - * DTO for validation response + * DTO for validation response with pagination support */ export class ValidateCodingCompletenessResponseDto { /** - * The validation results + * The validation results for the current page */ results!: ValidationResultDto[]; @@ -15,7 +15,37 @@ export class ValidateCodingCompletenessResponseDto { total!: number; /** - * The number of missing responses + * The number of missing responses (across all pages) */ missing!: number; + + /** + * Current page number (1-based) + */ + currentPage!: number; + + /** + * Number of items per page + */ + pageSize!: number; + + /** + * Total number of pages + */ + totalPages!: number; + + /** + * Whether there is a next page + */ + hasNextPage!: boolean; + + /** + * Whether there is a previous page + */ + hasPreviousPage!: boolean; + + /** + * Cache key for subsequent pagination requests and Excel downloads + */ + cacheKey?: string; } diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 72d814346..92650f48c 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -16,6 +16,7 @@ import { PersonService } from '../../database/services/person.service'; import { VariableAnalysisItemDto } from '../../../../../../api-dto/coding/variable-analysis-item.dto'; import { ValidateCodingCompletenessRequestDto } from '../../../../../../api-dto/coding/validate-coding-completeness-request.dto'; import { ValidateCodingCompletenessResponseDto } from '../../../../../../api-dto/coding/validate-coding-completeness-response.dto'; +import { ExportValidationResultsRequestDto } from '../../../../../../api-dto/coding/export-validation-results-request.dto'; @ApiTags('Admin Workspace Coding') @Controller('admin/workspace') @@ -741,20 +742,64 @@ export class WorkspaceCodingController { @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiBody({ - description: 'Expected combinations to validate', + description: 'Expected combinations to validate with optional pagination', type: ValidateCodingCompletenessRequestDto }) @ApiOkResponse({ - description: 'Validation results', + description: 'Validation results with pagination support', type: ValidateCodingCompletenessResponseDto }) async validateCodingCompleteness( @WorkspaceId() workspace_id: number, @Body() request: ValidateCodingCompletenessRequestDto ): Promise { + // Extract and validate pagination parameters + const page = Math.max(1, request.page || 1); + const pageSize = Math.min(Math.max(1, request.pageSize || 50), 500); // Max 500 items per page + return this.workspaceCodingService.validateCodingCompleteness( workspace_id, - request.expectedCombinations + request.expectedCombinations, + page, + pageSize + ); + } + + @Post(':workspace_id/coding/validate-completeness/export-excel') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiBody({ + description: 'Cache key to export validation results from Redis cache', + type: ExportValidationResultsRequestDto + }) + @ApiOkResponse({ + description: 'Validation results exported as Excel from cached data', + content: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + schema: { + type: 'string', + format: 'binary' + } + } + } + }) + async validateAndExportCodingCompleteness( + @WorkspaceId() workspace_id: number, + @Body() request: ExportValidationResultsRequestDto, + @Res() res: Response + ): Promise { + // Export the complete validation results from cache using cache key + const excelData = await this.workspaceCodingService.exportValidationResultsAsExcel( + workspace_id, + request.cacheKey ); + + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `validation-results-${timestamp}.xlsx`; + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(excelData); } } diff --git a/apps/backend/src/app/cache/cache.service.ts b/apps/backend/src/app/cache/cache.service.ts index db1c13048..fbadb3dcb 100644 --- a/apps/backend/src/app/cache/cache.service.ts +++ b/apps/backend/src/app/cache/cache.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; +import { ValidationResultDto } from '../../../../../api-dto/coding/validation-result.dto'; @Injectable() export class CacheService { @@ -86,4 +87,159 @@ export class CacheService { generateUnitResponseCacheKey(workspaceId: number, testPerson: string, unitId: string): string { return `responses:${workspaceId}:${testPerson}:${unitId}`; } + + /** + * Generate a cache key for validation results + * @param workspaceId The workspace ID + * @param hash Hash of expected combinations to ensure uniqueness + * @returns The cache key for validation results + */ + generateValidationCacheKey(workspaceId: number, hash: string): string { + return `validation:${workspaceId}:${hash}`; + } + + /** + * Store complete validation results in cache + * @param cacheKey The cache key + * @param results Complete validation results + * @param metadata Additional metadata (total, missing counts, etc.) + * @param ttl Time to live in seconds (defaults to 2 hours for validation results) + * @returns True if stored successfully + */ + async storeValidationResults( + cacheKey: string, + results: ValidationResultDto[], + metadata: { + total: number; + missing: number; + timestamp: number; + }, + ttl: number = 7200 // 2 hours default for validation results + ): Promise { + try { + const cacheData = { + results, + metadata, + cachedAt: Date.now() + }; + + await this.redis.set(cacheKey, JSON.stringify(cacheData), 'EX', ttl); + this.logger.log(`Stored validation results in cache: ${cacheKey} (${results.length} results)`); + return true; + } catch (error) { + this.logger.error(`Error storing validation results in cache: ${error.message}`, error.stack); + return false; + } + } + + /** + * Retrieve paginated validation results from cache + * @param cacheKey The cache key + * @param page Page number (1-based) + * @param pageSize Number of items per page + * @returns Paginated validation results with metadata + */ + async getPaginatedValidationResults( + cacheKey: string, + page: number, + pageSize: number + ): Promise<{ + results: ValidationResultDto[]; + metadata: { + total: number; + missing: number; + timestamp: number; + currentPage: number; + pageSize: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + } | null> { + try { + const cachedData = await this.get<{ + results: ValidationResultDto[]; + metadata: { + total: number; + missing: number; + timestamp: number; + }; + cachedAt: number; + }>(cacheKey); + + if (!cachedData) { + return null; + } + + const { results, metadata } = cachedData; + + // Calculate pagination + const totalPages = Math.ceil(results.length / pageSize); + const startIndex = (page - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, results.length); + const paginatedResults = results.slice(startIndex, endIndex); + + return { + results: paginatedResults, + metadata: { + ...metadata, + currentPage: page, + pageSize, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1 + } + }; + } catch (error) { + this.logger.error(`Error retrieving paginated validation results from cache: ${error.message}`, error.stack); + return null; + } + } + + /** + * Get complete validation results from cache (for Excel export) + * @param cacheKey The cache key + * @returns Complete validation results or null if not found + */ + async getCompleteValidationResults(cacheKey: string): Promise<{ + results: ValidationResultDto[]; + metadata: { + total: number; + missing: number; + timestamp: number; + }; + } | null> { + try { + this.logger.log(`Attempting to retrieve complete validation results from cache with key: ${cacheKey}`); + + const cachedData = await this.get<{ + results: ValidationResultDto[]; + metadata: { + total: number; + missing: number; + timestamp: number; + }; + cachedAt: number; + }>(cacheKey); + + if (!cachedData) { + this.logger.warn(`No cached data found for key: ${cacheKey}`); + // Check if key exists at all + const keyExists = await this.exists(cacheKey); + this.logger.warn(`Key exists in Redis: ${keyExists}`); + return null; + } + + this.logger.log(`Successfully retrieved cached validation results: ${cachedData.results.length} items`); + this.logger.log(`Cache metadata - Total: ${cachedData.metadata.total}, Missing: ${cachedData.metadata.missing}`); + + return { + results: cachedData.results, + metadata: cachedData.metadata + }; + } catch (error) { + this.logger.error(`Error retrieving complete validation results from cache: ${error.message}`, error.stack); + return null; + } + } } 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 ad8469d80..0026dca37 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -4,7 +4,10 @@ import { In, Like, Repository } from 'typeorm'; import * as Autocoder from '@iqb/responses'; import * as cheerio from 'cheerio'; import * as fastCsv from 'fast-csv'; +import * as ExcelJS from 'exceljs'; +import * as crypto from 'crypto'; import { ResponseStatusType } from '@iqb/responses'; +import { CacheService } from '../../cache/cache.service'; import FileUpload from '../entities/file_upload.entity'; import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; @@ -46,7 +49,8 @@ export class WorkspaceCodingService { private responseRepository: Repository, @InjectRepository(Setting) private settingRepository: Repository, - private jobQueueService: JobQueueService + private jobQueueService: JobQueueService, + private cacheService: CacheService ) {} private codingSchemeCache: Map = new Map(); @@ -55,6 +59,20 @@ export class WorkspaceCodingService { private testFileCache: Map; timestamp: number }> = new Map(); private readonly TEST_FILE_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes cache TTL + /** + * Generate a hash for expected combinations to create unique cache keys + * @param expectedCombinations Array of expected combinations + * @returns Hash string for cache key generation + */ + private generateExpectedCombinationsHash(expectedCombinations: ExpectedCombinationDto[]): string { + const sortedData = expectedCombinations + .map(combo => `${combo.unit_key}|${combo.login_name}|${combo.login_code}|${combo.booklet_id}|${combo.variable_id}`) + .sort() + .join('||'); + + return crypto.createHash('sha256').update(sortedData).digest('hex').substring(0, 16); + } + private async getTestFilesWithCache(workspace_id: number, unitAliasesArray: string[]): Promise> { const cacheEntry = this.testFileCache.get(workspace_id); const now = Date.now(); @@ -2220,28 +2238,225 @@ export class WorkspaceCodingService { } /** - * Validate completeness of coding responses + * Export validation results as Excel with complete database content from Redis cache + * @param workspaceId Workspace ID + * @param cacheKey Cache key to retrieve complete validation results + * @returns Excel buffer with complete data + */ + async exportValidationResultsAsExcel( + workspaceId: number, + cacheKey: string + ): Promise { + this.logger.log(`Exporting validation results as Excel for workspace ${workspaceId} using cache key ${cacheKey}`); + + // Validate input parameters + if (!cacheKey || typeof cacheKey !== 'string') { + const errorMessage = 'Invalid cache key provided'; + this.logger.error(`${errorMessage}: ${cacheKey}`); + throw new Error(errorMessage); + } + + try { + // Retrieve complete validation results from cache + this.logger.log(`Attempting to retrieve cached data with key: ${cacheKey}`); + const cachedData = await this.cacheService.getCompleteValidationResults(cacheKey); + + if (!cachedData) { + const errorMessage = 'Validation results not found in cache. Please run validation again.'; + this.logger.error(`No cached validation results found for cache key ${cacheKey}`); + // Additional logging to help debug cache issues + this.logger.error('Cache key format: validation:{workspaceId}:{hash}'); + this.logger.error(`Expected pattern: validation:${workspaceId}:*`); + throw new Error(errorMessage); + } + + const validationResults = cachedData.results; + this.logger.log(`Successfully retrieved ${validationResults.length} validation results from cache for export`); + + // Validate that we have actual data + if (!validationResults || validationResults.length === 0) { + const errorMessage = 'No validation data available for export. Please run validation again.'; + this.logger.error('Cached data exists but contains no validation results'); + throw new Error(errorMessage); + } + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Validation Results'); + + // Define columns for comprehensive data + worksheet.columns = [ + { header: 'Status', key: 'status', width: 10 }, + { header: 'Unit Key', key: 'unit_key', width: 15 }, + { header: 'Login Name', key: 'login_name', width: 15 }, + { header: 'Login Code', key: 'login_code', width: 15 }, + { header: 'Booklet ID', key: 'booklet_id', width: 15 }, + { header: 'Variable ID', key: 'variable_id', width: 15 }, + { header: 'Response Value', key: 'response_value', width: 20 }, + { header: 'Response Status', key: 'response_status', width: 15 }, + { header: 'Person ID', key: 'person_id', width: 12 }, + { header: 'Unit Name', key: 'unit_name', width: 20 }, + { header: 'Booklet Name', key: 'booklet_name', width: 20 }, + { header: 'Last Modified', key: 'last_modified', width: 20 } + ]; + + // Add header style + worksheet.getRow(1).font = { bold: true }; + worksheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + + // Process each validation result to get complete database content + for (const result of validationResults) { + const combination = result.combination; + let responseData = null; + let personData = null; + let unitData = null; + let bookletData = null; + + // Get complete data from database if the response exists + if (result.status === 'EXISTS') { + const query = this.responseRepository + .createQueryBuilder('response') + .leftJoin('response.unit', 'unit') + .leftJoin('unit.booklet', 'booklet') + .leftJoin('booklet.person', 'person') + .leftJoin('booklet.bookletinfo', 'bookletinfo') + .select([ + 'response.value', + 'response.status', + 'person.id', + 'person.login', + 'person.code', + 'unit.name', + 'unit.alias', + 'bookletinfo.name' + ]) + .where('unit.alias = :unitKey', { unitKey: combination.unit_key }) + .andWhere('person.login = :loginName', { loginName: combination.login_name }) + .andWhere('person.code = :loginCode', { loginCode: combination.login_code }) + .andWhere('bookletinfo.name = :bookletId', { bookletId: combination.booklet_id }) + .andWhere('response.variableid = :variableId', { variableId: combination.variable_id }) + .andWhere('response.value IS NOT NULL') + .andWhere('response.value != :empty', { empty: '' }); + + const responseEntity = await query.getOne(); + if (responseEntity) { + responseData = responseEntity; + personData = responseEntity.unit?.booklet?.person; + unitData = responseEntity.unit; + bookletData = responseEntity.unit?.booklet?.bookletinfo; + } + } + + // Add row to worksheet + worksheet.addRow({ + status: result.status, + unit_key: combination.unit_key, + login_name: combination.login_name, + login_code: combination.login_code, + booklet_id: combination.booklet_id, + variable_id: combination.variable_id, + response_value: responseData?.value || '', + response_status: responseData?.status || '', + person_id: personData?.id || '', + unit_name: unitData?.name || '', + booklet_name: bookletData?.name || '', + last_modified: '' // No timestamp field available in ResponseEntity + }); + } + + // Apply conditional formatting for status + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // Skip header row + const statusCell = row.getCell(1); + if (statusCell.value === 'EXISTS') { + statusCell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF90EE90' } // Light green + }; + } else if (statusCell.value === 'MISSING') { + statusCell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFA0A0' } // Light red + }; + } + } + }); + + // Auto-fit columns + worksheet.columns.forEach(column => { + if (column.header) { + column.width = Math.max(column.width || 10, column.header.length + 2); + } + }); + + // Generate Excel buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } catch (error) { + this.logger.error(`Error exporting validation results as Excel: ${error.message}`, error.stack); + throw new Error('Could not export validation results as Excel. Please check the database connection or query.'); + } + } + + /** + * Validate completeness of coding responses with Redis caching and complete backend processing * @param workspaceId Workspace ID * @param expectedCombinations Expected combinations from Excel - * @returns Validation results + * @param page Page number (1-based) + * @param pageSize Number of items per page + * @returns Validation results with pagination metadata */ async validateCodingCompleteness( workspaceId: number, - expectedCombinations: ExpectedCombinationDto[] + expectedCombinations: ExpectedCombinationDto[], + page: number = 1, + pageSize: number = 50 ): Promise { try { this.logger.log(`Validating coding completeness for workspace ${workspaceId} with ${expectedCombinations.length} expected combinations`); const startTime = Date.now(); - const results: ValidationResultDto[] = []; - let missingCount = 0; + // Generate cache key based on workspace and combinations hash + const combinationsHash = this.generateExpectedCombinationsHash(expectedCombinations); + const cacheKey = this.cacheService.generateValidationCacheKey(workspaceId, combinationsHash); + + // Try to get paginated results from cache first + let cachedResults = await this.cacheService.getPaginatedValidationResults(cacheKey, page, pageSize); + + if (cachedResults) { + this.logger.log(`Returning cached validation results for workspace ${workspaceId} (page ${page})`); + return { + results: cachedResults.results, + total: cachedResults.metadata.total, + missing: cachedResults.metadata.missing, + currentPage: cachedResults.metadata.currentPage, + pageSize: cachedResults.metadata.pageSize, + totalPages: cachedResults.metadata.totalPages, + hasNextPage: cachedResults.metadata.hasNextPage, + hasPreviousPage: cachedResults.metadata.hasPreviousPage, + cacheKey // Include cache key in response for subsequent requests + }; + } + + // No cache found - process ALL combinations and cache the complete results + this.logger.log(`No cached results found. Processing all ${expectedCombinations.length} combinations for workspace ${workspaceId}`); - // Process in batches to avoid overwhelming the database + const allResults: ValidationResultDto[] = []; + let totalMissingCount = 0; + + // Process all combinations in batches to avoid overwhelming the database const batchSize = 100; for (let i = 0; i < expectedCombinations.length; i += batchSize) { const batch = expectedCombinations.slice(i, i + batchSize); + this.logger.log(`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(expectedCombinations.length / batchSize)}`); - // Create a query to check for each combination in the batch + // Process each combination in the batch for (const expected of batch) { // Build a query to check if the response exists const responseExists = await this.responseRepository @@ -2262,23 +2477,67 @@ export class WorkspaceCodingService { // Add the result const status = responseExists > 0 ? 'EXISTS' : 'MISSING'; if (status === 'MISSING') { - missingCount += 1; + totalMissingCount += 1; } - results.push({ + allResults.push({ combination: expected, status }); } } + // Cache the complete results + const metadata = { + total: expectedCombinations.length, + missing: totalMissingCount, + timestamp: Date.now() + }; + + const cacheSuccess = await this.cacheService.storeValidationResults(cacheKey, allResults, metadata); + + if (cacheSuccess) { + this.logger.log(`Successfully cached validation results for workspace ${workspaceId}`); + } else { + this.logger.warn(`Failed to cache validation results for workspace ${workspaceId}`); + } + + // Now get the paginated results from the complete data + cachedResults = await this.cacheService.getPaginatedValidationResults(cacheKey, page, pageSize); + const endTime = Date.now(); - this.logger.log(`Validation completed in ${endTime - startTime}ms. Found ${missingCount} missing responses out of ${expectedCombinations.length} expected combinations.`); + this.logger.log(`Validation completed in ${endTime - startTime}ms. Processed all ${expectedCombinations.length} combinations with ${totalMissingCount} missing responses.`); + + if (cachedResults) { + return { + results: cachedResults.results, + total: cachedResults.metadata.total, + missing: cachedResults.metadata.missing, + currentPage: cachedResults.metadata.currentPage, + pageSize: cachedResults.metadata.pageSize, + totalPages: cachedResults.metadata.totalPages, + hasNextPage: cachedResults.metadata.hasNextPage, + hasPreviousPage: cachedResults.metadata.hasPreviousPage, + cacheKey // Include cache key in response for subsequent requests + }; + } + + // Fallback if cache retrieval fails - return direct pagination + const totalPages = Math.ceil(expectedCombinations.length / pageSize); + const startIndex = (page - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, expectedCombinations.length); + const paginatedResults = allResults.slice(startIndex, endIndex); return { - results, + results: paginatedResults, total: expectedCombinations.length, - missing: missingCount + missing: totalMissingCount, + currentPage: page, + pageSize, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + cacheKey }; } catch (error) { this.logger.error(`Error validating coding completeness: ${error.message}`, error.stack); diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html index eaeac7907..fec18a004 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html @@ -60,12 +60,51 @@

Fehler bei der Validierung

-

Validierungsergebnisse

-

- Insgesamt: {{validationResults.total}} | - Fehlend: {{validationResults.missing}} -

+
+
+

Validierungsergebnisse

+

+ Insgesamt: {{validationResults.total}} | + Fehlend: {{validationResults.missing}} | + Seite {{validationResults.currentPage}} von {{validationResults.totalPages}} +

+
+
+ +
+

+ + +
+
+ + + Seite {{currentPage}} von {{totalPages}} + + +
+ +
+ + +
+
+
@@ -90,6 +129,21 @@

Validierungsergebnisse

+ + +
+ + + Seite {{currentPage}} von {{totalPages}} + + +
diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts index 29594c2f1..c2402fc0f 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts @@ -49,6 +49,25 @@ export class CodingManagementManualComponent implements OnInit, OnDestroy { validationProgress: ValidationProgress | null = null; isLoading = false; + // Pagination state + currentPage = 1; + pageSize = 50; + expectedCombinations: ExpectedCombinationDto[] = []; + validationCacheKey: string | null = null; + + // Pagination helper properties + get totalPages(): number { + return this.validationResults?.totalPages || 0; + } + + get hasNextPage(): boolean { + return this.validationResults?.hasNextPage || false; + } + + get hasPreviousPage(): boolean { + return this.validationResults?.hasPreviousPage || false; + } + ngOnInit(): void { // Subscribe to validation progress updates this.validationStateService.validationProgress$ @@ -69,6 +88,8 @@ export class CodingManagementManualComponent implements OnInit, OnDestroy { this.validationResults = results; if (results) { + // Store cache key for pagination and Excel export + this.validationCacheKey = results.cacheKey || null; this.showSuccess(`Validierung abgeschlossen. ${results.missing} von ${results.total} Kombinationen fehlen.`); } }); @@ -216,9 +237,20 @@ export class CodingManagementManualComponent implements OnInit, OnDestroy { } /** - * Validate coding completeness + * Validate coding completeness with pagination */ private validateCodingCompleteness(expectedCombinations: ExpectedCombinationDto[]): void { + // Store expected combinations for pagination + this.expectedCombinations = expectedCombinations; + this.currentPage = 1; // Reset to first page + + this.loadValidationPage(1); + } + + /** + * Load a specific page of validation results + */ + private loadValidationPage(page: number): void { const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { @@ -226,17 +258,117 @@ export class CodingManagementManualComponent implements OnInit, OnDestroy { return; } - this.validationStateService.updateProgress(80, 'Validierung wird durchgeführt...'); + this.validationStateService.updateProgress(80, `Validierung wird durchgeführt (Seite ${page})...`); + this.currentPage = page; + + this.testPersonCodingService.validateCodingCompleteness( + workspaceId, + this.expectedCombinations, + page, + this.pageSize + ).subscribe({ + next: results => { + this.validationStateService.setValidationResults(results); + }, + error: () => { + this.validationStateService.setValidationError('Fehler bei der Validierung'); + } + }); + } + + /** + * Navigate to the next page + */ + nextPage(): void { + if (this.hasNextPage) { + this.loadValidationPage(this.currentPage + 1); + } + } + + /** + * Navigate to the previous page + */ + previousPage(): void { + if (this.hasPreviousPage) { + this.loadValidationPage(this.currentPage - 1); + } + } + + /** + * Navigate to a specific page + */ + goToPage(page: number): void { + if (page >= 1 && page <= this.totalPages) { + this.loadValidationPage(page); + } + } + + /** + * Change page size and reload current page + */ + changePageSize(newPageSize: number): void { + this.pageSize = newPageSize; + this.loadValidationPage(1); // Reset to first page when changing page size + } + + /** + * Download validation results as Excel file using cache key + */ + downloadExcel(): void { + const workspaceId = this.appService.selectedWorkspaceId; - this.testPersonCodingService.validateCodingCompleteness(workspaceId, expectedCombinations) - .subscribe({ - next: results => { - this.validationStateService.setValidationResults(results); - }, - error: () => { - this.validationStateService.setValidationError('Fehler bei der Validierung'); + if (!workspaceId || !this.validationCacheKey) { + this.showError('Keine Daten zum Herunterladen verfügbar. Bitte führen Sie zuerst eine Validierung durch.'); + return; + } + + this.isLoading = true; + + this.testPersonCodingService.downloadValidationResultsAsExcel( + workspaceId, + this.validationCacheKey + ).subscribe({ + next: blob => { + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + + // Generate filename with timestamp + const timestamp = new Date().toISOString().slice(0, 10); + link.download = `validation-results-${timestamp}.xlsx`; + + // Trigger download + document.body.appendChild(link); + link.click(); + + // Cleanup + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + this.showSuccess('Excel-Datei wurde erfolgreich heruntergeladen'); + this.isLoading = false; + }, + error: error => { + let errorMessage = 'Fehler beim Herunterladen der Excel-Datei'; + + // Provide more specific error messages + if (error.status === 404) { + errorMessage = 'Validierungsdaten nicht gefunden. Bitte führen Sie zuerst eine neue Validierung durch.'; + } else if (error.status === 400) { + errorMessage = 'Ungültiger Cache-Schlüssel. Bitte führen Sie eine neue Validierung durch.'; + } else if (error.status === 500) { + errorMessage = 'Server-Fehler beim Generieren der Excel-Datei. Bitte versuchen Sie es später erneut.'; + } else if (error.status === 0) { + errorMessage = 'Netzwerk-Fehler. Bitte überprüfen Sie Ihre Internetverbindung.'; + } else if (error.message && error.message.includes('cache')) { + errorMessage = 'Die Validierungsdaten sind nicht mehr verfügbar. Bitte führen Sie eine neue Validierung durch.'; } - }); + + this.showError(errorMessage); + this.isLoading = false; + } + }); } /** diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index 5cb4be7b1..56a4cf80f 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -290,17 +290,23 @@ export class TestPersonCodingService { } /** - * Validate completeness of coding responses + * Validate completeness of coding responses with pagination support * @param workspaceId Workspace ID * @param expectedCombinations Expected combinations from Excel - * @returns Observable of validation results + * @param page Page number (1-based, optional - defaults to 1) + * @param pageSize Number of items per page (optional - defaults to 50) + * @returns Observable of validation results with pagination metadata */ validateCodingCompleteness( workspaceId: number, - expectedCombinations: ExpectedCombinationDto[] + expectedCombinations: ExpectedCombinationDto[], + page?: number, + pageSize?: number ): Observable { const request: ValidateCodingCompletenessRequestDto = { - expectedCombinations + expectedCombinations, + page: page || 1, + pageSize: pageSize || 50 }; return this.http @@ -313,8 +319,43 @@ export class TestPersonCodingService { catchError(() => of({ results: [], total: 0, - missing: 0 + missing: 0, + currentPage: page || 1, + pageSize: pageSize || 50, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false })) ); } + + /** + * Download validation results as Excel file using cache key + * @param workspaceId Workspace ID + * @param cacheKey Cache key from validation results + * @returns Observable of Excel file as Blob + */ + downloadValidationResultsAsExcel( + workspaceId: number, + cacheKey: string + ): Observable { + const request = { + cacheKey + }; + + return this.http + .post( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/validate-completeness/export-excel`, + request, + { + headers: this.authHeader, + responseType: 'blob' + } + ) + .pipe( + catchError(error => { + throw error; + }) + ); + } } From a8448c3325dd59298f6b88cf4f28735351f92ac9 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:24:03 +0200 Subject: [PATCH 5/8] Create a job to calculate coding statistics --- .../workspace/workspace-coding.controller.ts | 20 +++++- .../services/workspace-coding.service.ts | 44 +++++------- .../src/app/job-queue/job-queue.module.ts | 6 +- .../src/app/job-queue/job-queue.service.ts | 22 +++++- .../processors/coding-statistics.processor.ts | 35 ++++++++++ .../coding-management.component.ts | 70 ++++++++++++++----- .../src/app/services/backend.service.ts | 8 ++- .../src/app/services/coding.service.ts | 20 ++++-- 8 files changed, 170 insertions(+), 55 deletions(-) create mode 100644 apps/backend/src/app/job-queue/processors/coding-statistics.processor.ts diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 92650f48c..2be68dc4c 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -180,6 +180,24 @@ export class WorkspaceCodingController { return this.workspaceCodingService.getCodingStatistics(workspace_id); } + @Post(':workspace_id/coding/statistics/job') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'Coding statistics job created successfully.', + schema: { + type: 'object', + properties: { + jobId: { type: 'string' }, + message: { type: 'string' } + } + } + }) + async createCodingStatisticsJob(@WorkspaceId() workspace_id: number): Promise<{ jobId: string; message: string }> { + return this.workspaceCodingService.createCodingStatisticsJob(workspace_id); + } + @Get(':workspace_id/coding/job/:jobId') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiTags('coding') @@ -218,7 +236,7 @@ export class WorkspaceCodingController { } }) async getJobStatus(@Param('jobId') jobId: string): Promise<{ status: string; progress: number; result?: CodingStatistics; error?: string } | { error: string }> { - const status = this.workspaceCodingService.getJobStatus(jobId); + const status = await this.workspaceCodingService.getJobStatus(jobId); if (!status) { return { error: `Job with ID ${jobId} not found` }; } 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 0026dca37..f36382555 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -193,36 +193,15 @@ export class WorkspaceCodingService { } } - // In-memory job status map removed as we now use only Bull for job management - - async getAllJobs(workspaceId?: number): Promise<{ - jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; - progress: number; - result?: CodingStatistics; - error?: string; - workspaceId?: number; - createdAt?: Date; - groupNames?: string; - durationMs?: number; - completedAt?: Date; - }[]> { - // Use getBullJobs for all workspaces - if (workspaceId !== undefined) { - return this.getBullJobs(workspaceId); - } - - // If no workspaceId is provided, we need to get jobs for all workspaces - // Since we don't have a way to get all jobs from Bull without a workspaceId, - // we'll return an empty array for now - this.logger.warn('getAllJobs called without workspaceId, returning empty array'); - return []; - } async getJobStatus(jobId: string): Promise<{ status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string } | null> { try { - // Get job from Bull queue - const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + let bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + + if (!bullJob) { + bullJob = await this.jobQueueService.getCodingStatisticsJob(jobId) as any; + } + if (bullJob) { // Get job state and progress const state = await bullJob.getState(); @@ -276,6 +255,17 @@ export class WorkspaceCodingService { } } + async createCodingStatisticsJob(workspaceId: number): Promise<{ jobId: string; message: string }> { + try { + const job = await this.jobQueueService.addCodingStatisticsJob(workspaceId); + this.logger.log(`Created coding statistics job ${job.id} for workspace ${workspaceId}`); + return { jobId: job.id.toString(), message: 'Coding statistics job created' }; + } catch (error) { + this.logger.error(`Error creating coding statistics job: ${error.message}`, error.stack); + throw error; + } + } + async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> { try { const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); diff --git a/apps/backend/src/app/job-queue/job-queue.module.ts b/apps/backend/src/app/job-queue/job-queue.module.ts index 0e67785d8..19dbe0498 100644 --- a/apps/backend/src/app/job-queue/job-queue.module.ts +++ b/apps/backend/src/app/job-queue/job-queue.module.ts @@ -3,6 +3,7 @@ import { BullModule } from '@nestjs/bull'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JobQueueService } from './job-queue.service'; import { TestPersonCodingProcessor } from './processors/test-person-coding.processor'; +import { CodingStatisticsProcessor } from './processors/coding-statistics.processor'; // eslint-disable-next-line import/no-cycle import { DatabaseModule } from '../database/database.module'; @@ -22,9 +23,12 @@ import { DatabaseModule } from '../database/database.module'; BullModule.registerQueue({ name: 'test-person-coding' }), + BullModule.registerQueue({ + name: 'coding-statistics' + }), forwardRef(() => DatabaseModule) ], - providers: [JobQueueService, TestPersonCodingProcessor], + providers: [JobQueueService, TestPersonCodingProcessor, CodingStatisticsProcessor], exports: [JobQueueService] }) export class JobQueueModule {} diff --git a/apps/backend/src/app/job-queue/job-queue.service.ts b/apps/backend/src/app/job-queue/job-queue.service.ts index e63aae154..ce201d8e3 100644 --- a/apps/backend/src/app/job-queue/job-queue.service.ts +++ b/apps/backend/src/app/job-queue/job-queue.service.ts @@ -9,6 +9,10 @@ export interface TestPersonCodingJobData { isPaused?: boolean; } +export interface CodingStatisticsJobData { + workspaceId: number; +} + export interface RedisConnectionStatus { connected: boolean; message: string; @@ -34,7 +38,8 @@ export class JobQueueService { private readonly logger = new Logger(JobQueueService.name); constructor( - @InjectQueue('test-person-coding') private testPersonCodingQueue: Queue + @InjectQueue('test-person-coding') private testPersonCodingQueue: Queue, + @InjectQueue('coding-statistics') private codingStatisticsQueue: Queue ) {} /** @@ -60,6 +65,21 @@ export class JobQueueService { return this.testPersonCodingQueue.getJob(jobId); } + /** + * Add a coding statistics job to the queue + */ + async addCodingStatisticsJob(workspaceId: number, options?: JobOptions): Promise> { + this.logger.log(`Adding coding statistics job for workspace ${workspaceId}`); + return this.codingStatisticsQueue.add({ workspaceId }, options); + } + + /** + * Get a coding statistics job by ID + */ + async getCodingStatisticsJob(jobId: string): Promise> { + return this.codingStatisticsQueue.getJob(jobId); + } + /** * Get all test person coding jobs for a workspace * @param workspaceId The workspace ID diff --git a/apps/backend/src/app/job-queue/processors/coding-statistics.processor.ts b/apps/backend/src/app/job-queue/processors/coding-statistics.processor.ts new file mode 100644 index 000000000..b4f67851f --- /dev/null +++ b/apps/backend/src/app/job-queue/processors/coding-statistics.processor.ts @@ -0,0 +1,35 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; +import { Job } from 'bull'; +import { CodingStatistics } from '../../database/services/shared-types'; +import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; + +export interface CodingStatisticsJobData { + workspaceId: number; +} + +@Injectable() +@Processor('coding-statistics') +export class CodingStatisticsProcessor { + private readonly logger = new Logger(CodingStatisticsProcessor.name); + + constructor( + @Inject(forwardRef(() => WorkspaceCodingService)) + private workspaceCodingService: WorkspaceCodingService + ) {} + + @Process() + async process(job: Job): Promise { + this.logger.log(`Processing coding statistics job ${job.id} for workspace ${job.data.workspaceId}`); + try { + await job.progress(0); + const result = await this.workspaceCodingService.getCodingStatistics(job.data.workspaceId); + await job.progress(100); + this.logger.log(`Coding statistics job ${job.id} completed successfully`); + return result; + } catch (error) { + this.logger.error(`Error processing coding statistics job ${job.id}: ${error.message}`, error.stack); + throw error; + } + } +} diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts index 18c2a01c1..6316079db 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts @@ -7,9 +7,12 @@ import { catchError, finalize, debounceTime, - distinctUntilChanged + distinctUntilChanged, + switchMap, + takeUntil, + takeWhile } from 'rxjs/operators'; -import { of, Subject } from 'rxjs'; +import { of, Subject, timer } from 'rxjs'; import { MatCell, MatCellDef, MatColumnDef, @@ -104,6 +107,7 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr totalRecords = 0; pageIndex = 0; filterTextChanged = new Subject(); + private destroy$ = new Subject(); codingStatistics: CodingStatistics = { totalResponses: 0, statusCounts: {} @@ -134,25 +138,51 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr const workspaceId = this.appService.selectedWorkspaceId; this.isLoadingStatistics = true; - this.backendService.getCodingStatistics(workspaceId) + this.backendService.createCodingStatisticsJob(workspaceId) .pipe( - catchError(() => { - this.isLoadingStatistics = false; - this.snackBar.open('Fehler beim Abrufen der Kodierstatistiken', 'Schließen', { - duration: 5000, - panelClass: ['error-snackbar'] - }); - return of({ - totalResponses: 0, - statusCounts: {} - }); - }), - finalize(() => { - this.isLoadingStatistics = false; - }) + catchError(() => of({ jobId: '' as string, message: 'Fehler beim Erstellen des Statistik-Jobs' })) ) - .subscribe(statistics => { - this.codingStatistics = statistics; + .subscribe(({ jobId }) => { + if (!jobId) { + // Fallback: fetch directly + this.backendService.getCodingStatistics(workspaceId) + .pipe( + catchError(() => { + this.snackBar.open('Fehler beim Abrufen der Kodierstatistiken', 'Schließen', { + duration: 5000, + panelClass: ['error-snackbar'] + }); + return of({ + totalResponses: 0, + statusCounts: {} + }); + }), + finalize(() => { + this.isLoadingStatistics = false; + }) + ) + .subscribe(statistics => { + this.codingStatistics = statistics; + }); + return; + } + + timer(0, 2000) + .pipe( + takeUntil(this.destroy$), + switchMap(() => this.backendService.getCodingJobStatus(workspaceId, jobId)), + takeWhile(status => ['pending', 'processing'].includes(status.status), true), + finalize(() => { + this.isLoadingStatistics = false; + }) + ) + .subscribe(status => { + if (status.status === 'completed' && status.result) { + this.codingStatistics = status.result; + } else if (['failed', 'cancelled', 'paused'].includes(status.status)) { + this.snackBar.open(`Statistik-Job ${status.status}`, 'Schließen', { duration: 5000, panelClass: ['error-snackbar'] }); + } + }); }); } @@ -249,6 +279,8 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr } ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); this.filterTextChanged.complete(); } diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 751a7ee5e..2fce6aa02 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -215,7 +215,7 @@ export class BackendService { } getCodingJobStatus(workspace_id: number, jobId: string): Observable<{ - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -237,7 +237,7 @@ export class BackendService { getAllCodingJobs(workspace_id: number): Observable<{ jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -268,6 +268,10 @@ export class BackendService { return this.codingService.getCodingStatistics(workspace_id); } + createCodingStatisticsJob(workspace_id: number): Observable<{ jobId: string; message: string }> { + return this.codingService.createCodingStatisticsJob(workspace_id); + } + getVariableAnalysis( workspace_id: number, page: number = 1, diff --git a/apps/frontend/src/app/services/coding.service.ts b/apps/frontend/src/app/services/coding.service.ts index d56795e67..70344a338 100644 --- a/apps/frontend/src/app/services/coding.service.ts +++ b/apps/frontend/src/app/services/coding.service.ts @@ -95,7 +95,7 @@ export class CodingService { } getCodingJobStatus(workspace_id: number, jobId: string): Observable<{ - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -107,7 +107,7 @@ export class CodingService { }> { return this.http .get<{ - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -151,7 +151,7 @@ export class CodingService { getAllCodingJobs(workspace_id: number): Observable<{ jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -166,7 +166,7 @@ export class CodingService { return this.http .get<{ jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: { totalResponses: number; @@ -244,6 +244,18 @@ export class CodingService { ); } + createCodingStatisticsJob(workspace_id: number): Observable<{ jobId: string; message: string }> { + return this.http + .post<{ jobId: string; message: string }>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/statistics/job`, + {}, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ jobId: '', message: 'Failed to create job' })) + ); + } + getResponsesByStatus(workspace_id: number, status: string, page: number = 1, limit: number = 100): Observable> { const params = new HttpParams() .set('page', page.toString()) From d8e9a09826fceb557d5f84c67058c1e984985655 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:27:49 +0200 Subject: [PATCH 6/8] Improve coding job display --- .../admin/coding-job/coding-job.controller.ts | 7 +- .../admin/coding-job/dto/coding-job.dto.ts | 37 +- .../workspace/workspace-coding.controller.ts | 33 ++ .../database/services/coding-job.service.ts | 73 +++- .../services/workspace-coding.service.ts | 54 ++- .../coding-job/coding-job.controller.ts | 17 +- .../coding-job-dialog.component.html | 68 ++-- .../coding-job-dialog.component.scss | 44 ++- .../coding-job-dialog.component.ts | 137 +++++--- .../coding-jobs/coding-jobs.component.ts | 331 ++++-------------- .../src/app/coding/models/coding-job.model.ts | 4 +- .../src/app/services/backend.service.ts | 84 +---- 12 files changed, 431 insertions(+), 458 deletions(-) diff --git a/apps/backend/src/app/admin/coding-job/coding-job.controller.ts b/apps/backend/src/app/admin/coding-job/coding-job.controller.ts index daf296e65..6a08bacf4 100644 --- a/apps/backend/src/app/admin/coding-job/coding-job.controller.ts +++ b/apps/backend/src/app/admin/coding-job/coding-job.controller.ts @@ -91,7 +91,12 @@ export class CodingJobController { try { const result = await this.codingJobService.getCodingJobs(workspaceId, page, limit); return { - data: result.data.map(job => CodingJobDto.fromEntity(job)), + data: result.data.map(job => CodingJobDto.fromEntity( + job, + job.assignedCoders || [], + job.assignedVariables || [], + job.assignedVariableBundles || [] + )), total: result.total, page: result.page, limit: result.limit diff --git a/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts b/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts index 98145c3cc..3d6f48026 100644 --- a/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts +++ b/apps/backend/src/app/admin/coding-job/dto/coding-job.dto.ts @@ -59,6 +59,22 @@ export class CodingJobDto { }) assigned_coders?: number[]; + @ApiProperty({ + description: 'Variable IDs assigned to the coding job', + type: [String], + example: ['var1', 'var2', 'var3'], + required: false + }) + assigned_variables?: string[]; + + @ApiProperty({ + description: 'Variable bundle names assigned to the coding job', + type: [String], + example: ['Bundle A', 'Bundle B'], + required: false + }) + assigned_variable_bundles?: string[]; + @ApiProperty({ description: 'Variables assigned to the coding job', type: [VariableDto], @@ -76,9 +92,17 @@ export class CodingJobDto { /** * Create a CodingJobDto from a CodingJob entity * @param entity The CodingJob entity + * @param assignedCoders Optional array of assigned coder IDs + * @param assignedVariables Optional array of assigned variable IDs + * @param assignedVariableBundles Optional array of assigned variable bundle names * @returns A CodingJobDto */ - static fromEntity(entity: CodingJob): CodingJobDto { + static fromEntity( + entity: CodingJob, + assignedCoders?: number[], + assignedVariables?: string[], + assignedVariableBundles?: string[] + ): CodingJobDto { const dto = new CodingJobDto(); dto.id = entity.id; dto.workspace_id = entity.workspace_id; @@ -87,6 +111,17 @@ export class CodingJobDto { dto.status = entity.status; dto.created_at = entity.created_at; dto.updated_at = entity.updated_at; + + if (assignedCoders) { + dto.assigned_coders = assignedCoders; + } + if (assignedVariables) { + dto.assigned_variables = assignedVariables; + } + if (assignedVariableBundles) { + dto.assigned_variable_bundles = assignedVariableBundles; + } + return dto; } } diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 2be68dc4c..b42bc5413 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -820,4 +820,37 @@ export class WorkspaceCodingController { res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(excelData); } + + @Get(':workspace_id/coding/incomplete-variables') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiQuery({ + name: 'unitName', + required: false, + description: 'Filter by unit name', + type: String + }) + @ApiOkResponse({ + description: 'CODING_INCOMPLETE variables retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + unitName: { type: 'string', description: 'Unit name' }, + variableId: { type: 'string', description: 'Variable ID' } + } + } + } + }) + async getCodingIncompleteVariables( + @WorkspaceId() workspace_id: number, + @Query('unitName') unitName?: string + ): Promise<{ unitName: string; variableId: string }[]> { + return this.workspaceCodingService.getCodingIncompleteVariables( + workspace_id, + unitName + ); + } } diff --git a/apps/backend/src/app/database/services/coding-job.service.ts b/apps/backend/src/app/database/services/coding-job.service.ts index 6af718688..c5472fd71 100644 --- a/apps/backend/src/app/database/services/coding-job.service.ts +++ b/apps/backend/src/app/database/services/coding-job.service.ts @@ -32,13 +32,17 @@ export class CodingJobService { * @param workspaceId The ID of the workspace * @param page The page number (1-based) * @param limit The number of items per page - * @returns Paginated coding jobs with metadata + * @returns Paginated coding jobs with metadata, assigned coders, variables, and variable bundles */ async getCodingJobs( workspaceId: number, page: number = 1, limit: number = 10 - ): Promise<{ data: CodingJob[]; total: number; page: number; limit: number }> { + ): Promise<{ data: (CodingJob & { + assignedCoders?: number[]; + assignedVariables?: string[]; + assignedVariableBundles?: string[] + })[]; total: number; page: number; limit: number }> { const validPage = page > 0 ? page : 1; const validLimit = limit > 0 ? limit : 10; @@ -48,13 +52,61 @@ export class CodingJobService { where: { workspace_id: workspaceId } }); - const data = await this.codingJobRepository.find({ + const jobs = await this.codingJobRepository.find({ where: { workspace_id: workspaceId }, order: { created_at: 'DESC' }, skip, take: validLimit }); + const jobIds = jobs.map(job => job.id); + + const [allCoders, allVariables, variableBundleEntities] = await Promise.all([ + this.codingJobCoderRepository.find({ + where: { coding_job_id: In(jobIds) } + }), + this.codingJobVariableRepository.find({ + where: { coding_job_id: In(jobIds) } + }), + this.codingJobVariableBundleRepository.find({ + where: { coding_job_id: In(jobIds) }, + relations: ['variable_bundle'] + }) + ]); + + const codersByJobId = new Map(); + allCoders.forEach(coder => { + if (!codersByJobId.has(coder.coding_job_id)) { + codersByJobId.set(coder.coding_job_id, []); + } + codersByJobId.get(coder.coding_job_id)!.push(coder.user_id); + }); + + const variablesByJobId = new Map(); + allVariables.forEach(variable => { + if (!variablesByJobId.has(variable.coding_job_id)) { + variablesByJobId.set(variable.coding_job_id, []); + } + variablesByJobId.get(variable.coding_job_id)!.push(variable.variable_id); + }); + + const variableBundlesByJobId = new Map(); + variableBundleEntities.forEach(bundleAssignment => { + if (!variableBundlesByJobId.has(bundleAssignment.coding_job_id)) { + variableBundlesByJobId.set(bundleAssignment.coding_job_id, []); + } + if (bundleAssignment.variable_bundle?.name) { + variableBundlesByJobId.get(bundleAssignment.coding_job_id)!.push(bundleAssignment.variable_bundle.name); + } + }); + + const data = jobs.map(job => ({ + ...job, + assignedCoders: codersByJobId.get(job.id) || [], + assignedVariables: variablesByJobId.get(job.id) || [], + assignedVariableBundles: variableBundlesByJobId.get(job.id) || [] + })); + console.log(data); return { data, total, @@ -195,7 +247,6 @@ export class CodingJobService { ): Promise { const codingJob = await this.getCodingJob(id, workspaceId); - // Update the coding job if (updateCodingJobDto.name !== undefined) { codingJob.codingJob.name = updateCodingJobDto.name; } @@ -206,44 +257,31 @@ export class CodingJobService { codingJob.codingJob.status = updateCodingJobDto.status; } - // Save the coding job const savedCodingJob = await this.codingJobRepository.save(codingJob.codingJob); - // Update assigned coders if provided if (updateCodingJobDto.assignedCoders !== undefined) { - // Remove existing coders await this.codingJobCoderRepository.delete({ coding_job_id: id }); - // Assign new coders if (updateCodingJobDto.assignedCoders.length > 0) { await this.assignCoders(id, updateCodingJobDto.assignedCoders); } } - // Update variables if provided if (updateCodingJobDto.variables !== undefined) { - // Remove existing variables await this.codingJobVariableRepository.delete({ coding_job_id: id }); - // Assign new variables if (updateCodingJobDto.variables.length > 0) { await this.assignVariables(id, updateCodingJobDto.variables); } } - // Update variable bundles if provided if (updateCodingJobDto.variableBundleIds !== undefined) { - // Remove existing variable bundles await this.codingJobVariableBundleRepository.delete({ coding_job_id: id }); - // Assign new variable bundles if (updateCodingJobDto.variableBundleIds.length > 0) { await this.assignVariableBundles(id, updateCodingJobDto.variableBundleIds); } } else if (updateCodingJobDto.variableBundles !== undefined) { - // Remove existing variable bundles await this.codingJobVariableBundleRepository.delete({ coding_job_id: id }); - // Handle variable bundles without IDs by using their variables directly if (updateCodingJobDto.variableBundles.length > 0) { - // If the first bundle has an ID, use the IDs approach if (updateCodingJobDto.variableBundles[0].id) { const bundleIds = updateCodingJobDto.variableBundles .filter(bundle => bundle.id) @@ -253,7 +291,6 @@ export class CodingJobService { await this.assignVariableBundles(id, bundleIds); } } else { - // Otherwise, extract variables and assign them directly const variables = updateCodingJobDto.variableBundles.flatMap(bundle => bundle.variables || []); if (variables.length > 0) { await this.assignVariables(id, variables); 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 f36382555..39e813a58 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -193,7 +193,6 @@ export class WorkspaceCodingService { } } - async getJobStatus(jobId: string): Promise<{ status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string } | null> { try { let bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); @@ -2263,18 +2262,15 @@ export class WorkspaceCodingService { const validationResults = cachedData.results; this.logger.log(`Successfully retrieved ${validationResults.length} validation results from cache for export`); - // Validate that we have actual data if (!validationResults || validationResults.length === 0) { const errorMessage = 'No validation data available for export. Please run validation again.'; this.logger.error('Cached data exists but contains no validation results'); throw new Error(errorMessage); } - // Create a new workbook const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('Validation Results'); - // Define columns for comprehensive data worksheet.columns = [ { header: 'Status', key: 'status', width: 10 }, { header: 'Unit Key', key: 'unit_key', width: 15 }, @@ -2298,7 +2294,6 @@ export class WorkspaceCodingService { fgColor: { argb: 'FFE0E0E0' } }; - // Process each validation result to get complete database content for (const result of validationResults) { const combination = result.combination; let responseData = null; @@ -2306,7 +2301,6 @@ export class WorkspaceCodingService { let unitData = null; let bookletData = null; - // Get complete data from database if the response exists if (result.status === 'EXISTS') { const query = this.responseRepository .createQueryBuilder('response') @@ -2341,7 +2335,6 @@ export class WorkspaceCodingService { } } - // Add row to worksheet worksheet.addRow({ status: result.status, unit_key: combination.unit_key, @@ -2358,7 +2351,6 @@ export class WorkspaceCodingService { }); } - // Apply conditional formatting for status worksheet.eachRow((row, rowNumber) => { if (rowNumber > 1) { // Skip header row const statusCell = row.getCell(1); @@ -2512,7 +2504,6 @@ export class WorkspaceCodingService { }; } - // Fallback if cache retrieval fails - return direct pagination const totalPages = Math.ceil(expectedCombinations.length / pageSize); const startIndex = (page - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, expectedCombinations.length); @@ -2534,4 +2525,49 @@ export class WorkspaceCodingService { throw new Error('Could not validate coding completeness. Please check the database connection or query.'); } } + + async getCodingIncompleteVariables( + workspaceId: number, + unitName?: string + ): Promise<{ unitName: string; variableId: string }[]> { + try { + this.logger.log(`Getting CODING_INCOMPLETE variables for workspace ${workspaceId}${unitName ? ` and unit ${unitName}` : ''}`); + + const queryBuilder = this.responseRepository.createQueryBuilder('response') + .leftJoinAndSelect('response.unit', 'unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.person', 'person') + .where('response.codedStatus = :status', { status: 'CODING_INCOMPLETE' }) + .andWhere('person.workspace_id = :workspace_id', { workspace_id: workspaceId }); + + if (unitName) { + queryBuilder.andWhere('unit.name = :unitName', { unitName }); + } + + const responses = await queryBuilder.getMany(); + + const uniqueVariables = new Map(); + + responses.forEach(response => { + const unit = response.unit; + if (unit && response.variableid) { + const key = `${unit.name}|${response.variableid}`; + if (!uniqueVariables.has(key)) { + uniqueVariables.set(key, { + unitName: unit.name, + variableId: response.variableid + }); + } + } + }); + + const result = Array.from(uniqueVariables.values()); + this.logger.log(`Found ${result.length} unique CODING_INCOMPLETE variables`); + + return result; + } catch (error) { + this.logger.error(`Error getting CODING_INCOMPLETE variables: ${error.message}`, error.stack); + throw new Error('Could not get CODING_INCOMPLETE variables. Please check the database connection.'); + } + } } diff --git a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts index e3bc416ee..8f7783304 100644 --- a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts +++ b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts @@ -31,8 +31,6 @@ import { CodingJobService } from '../../database/services/coding-job.service'; import { CodingJobDto } from '../../admin/coding-job/dto/coding-job.dto'; import { CreateCodingJobDto } from '../../admin/coding-job/dto/create-coding-job.dto'; import { UpdateCodingJobDto } from '../../admin/coding-job/dto/update-coding-job.dto'; -import { VariableBundleDto } from '../../admin/variable-bundle/dto/variable-bundle.dto'; -import { VariableDto } from '../../admin/variable-bundle/dto/variable.dto'; @ApiTags('WSG Admin Coding Jobs') @Controller('wsg-admin/workspace/:workspace_id/coding-job') @@ -90,7 +88,7 @@ export class WsgCodingJobController { try { const result = await this.codingJobService.getCodingJobs(workspaceId, page, limit); return { - data: result.data.map(job => CodingJobDto.fromEntity(job)), + data: result.data, total: result.total, page: result.page, limit: result.limit @@ -135,16 +133,7 @@ export class WsgCodingJobController { ): Promise { try { const result = await this.codingJobService.getCodingJob(id, workspaceId); - const dto = CodingJobDto.fromEntity(result.codingJob); - dto.assigned_coders = result.assignedCoders; - dto.variables = result.variables.map(v => { - const variableDto = new VariableDto(); - variableDto.unitName = v.unitName; - variableDto.variableId = v.variableId; - return variableDto; - }); - dto.variable_bundles = result.variableBundles.map(vb => VariableBundleDto.fromEntity(vb)); - return dto; + return result.codingJob; } catch (error) { if (error instanceof NotFoundException) { throw error; @@ -228,7 +217,7 @@ export class WsgCodingJobController { workspaceId, updateCodingJobDto ); - return CodingJobDto.fromEntity(codingJob); + return codingJob; } catch (error) { if (error instanceof NotFoundException) { throw error; diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html index 50de26f5e..2277cc9ae 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html @@ -49,7 +49,7 @@

Ausgewählte Elemente

Kodierer: - {{ coders.length }} + {{ selectedCoders.selected.length }}
@@ -98,35 +98,52 @@
Ausgewählte Variablenbündel
- +
-
- info -

- Kodierer werden diesem Job über die Kodierer-Verwaltung zugewiesen. - Hier sehen Sie eine Übersicht der bereits zugewiesenen Kodierer. -

-
+

Wählen Sie die Kodierer aus, die diesem Job zugewiesen werden sollen.

- @if (isLoadingCoders) { + @if (isLoadingAvailableCoders) {
-

Lade Kodierer...

+

Lade verfügbare Kodierer...

} - @if (!isLoadingCoders && coders.length > 0) { + @if (!isLoadingAvailableCoders && availableCoders.length > 0) { + +
+ +
+ {{ selectedCoders.selected.length }} von {{ availableCoders.length }} Kodierern ausgewählt +
+
+ +
- @for (coder of coders; track coder.id) { -
+ @for (coder of availableCoders; track coder.id) { +
+
+ + +
person

{{ coder.displayName || coder.name }}

{{ coder.name }}

+ @if (coder.email) { +

{{ coder.email }}

+ }
ID: {{ coder.id }}
@@ -135,11 +152,11 @@

{{ coder.displayName || coder.name }}

} - @if (!isLoadingCoders && coders.length === 0) { + @if (!isLoadingAvailableCoders && availableCoders.length === 0) {
person -

Keine Kodierer zugewiesen

-

Diesem Kodierjob sind noch keine Kodierer zugewiesen. Kodierer können über die Kodierer-Verwaltung zugewiesen werden.

+

Keine Kodierer verfügbar

+

Im Arbeitsbereich sind keine Kodierer verfügbar. Fügen Sie zuerst Benutzer zum Arbeitsbereich hinzu.

}
@@ -210,8 +227,12 @@

Keine Kodierer zugewiesen

(change)="$event ? selectedVariables.toggle(variable) : null">
-
{{ variable.variableId }}
-
Aufgabe: {{ variable.unitName }}
+
+ {{ variable.unitName }}_{{ variable.variableId }} + @if (data.isEdit && isVariableOriginallyAssigned(variable)) { + check_circle + } +
@@ -225,7 +246,7 @@

Keine Kodierer zugewiesen

[pageIndex]="variableAnalysisPageIndex" [pageSizeOptions]="variableAnalysisPageSizeOptions" showFirstLastButtons - (page)="onPageChange($event)" + (page)="onPageChange()" aria-label="Seiten auswählen"> } @@ -281,7 +302,12 @@

Keine Variablen verfügbar

(click)="$event.stopPropagation()" (change)="$event ? selectedVariableBundles.toggle(bundle) : null"> -

{{ bundle.name }}

+

+ {{ bundle.name }} + @if (data.isEdit && isBundleOriginallyAssigned(bundle)) { + check_circle + } +

{{ bundle.description || 'Keine Beschreibung' }}

diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss index c7cfad9b8..cabf3539f 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss @@ -278,13 +278,33 @@ gap: 16px; .coder-card { + position: relative; display: flex; align-items: center; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; + border: 2px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; padding: 16px; background-color: white; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + border-color: #1976d2; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + + &.selected { + border-color: #4caf50; + background-color: #f1f8e9; + box-shadow: 0 4px 8px rgba(76, 175, 80, 0.2); + } + + .coder-card-header { + position: absolute; + top: 8px; + right: 8px; + } .coder-avatar { display: flex; @@ -369,6 +389,16 @@ .variable-id { font-weight: 500; font-size: 1rem; + display: flex; + align-items: center; + gap: 8px; + + .assigned-indicator { + color: #4caf50; + font-size: 18px; + width: 18px; + height: 18px; + } } .unit-name { @@ -418,6 +448,16 @@ margin: 0 0 0 8px; font-size: 1.1rem; font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + + .assigned-indicator { + color: #4caf50; + font-size: 20px; + width: 20px; + height: 20px; + } } } diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts index b7bfe60a7..833f9594c 100644 --- a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts @@ -14,7 +14,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule } from '@angular/material/chips'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSortModule } from '@angular/material/sort'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDividerModule } from '@angular/material/divider'; @@ -23,6 +23,7 @@ import { MatExpansionModule } from '@angular/material/expansion'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateModule } from '@ngx-translate/core'; import { SelectionModel } from '@angular/cdk/collections'; +import { MatTooltip } from '@angular/material/tooltip'; import { CodingJob, VariableBundle, Variable } from '../../models/coding-job.model'; import { Coder } from '../../models/coder.model'; import { BackendService } from '../../../services/backend.service'; @@ -59,7 +60,8 @@ export interface CodingJobDialogData { MatDividerModule, MatTabsModule, MatExpansionModule, - TranslateModule + TranslateModule, + MatTooltip ] }) export class CodingJobDialogComponent implements OnInit { @@ -82,6 +84,9 @@ export class CodingJobDialogComponent implements OnInit { // Coders coders: Coder[] = []; isLoadingCoders = false; + availableCoders: Coder[] = []; + selectedCoders = new SelectionModel(true, []); + isLoadingAvailableCoders = false; // Variable bundles variableBundles: VariableBundle[] = []; @@ -109,15 +114,33 @@ export class CodingJobDialogComponent implements OnInit { ngOnInit(): void { this.initForm(); - this.loadVariableAnalysisItems(); + this.loadCodingIncompleteVariables(); this.loadVariableBundles(); - + this.loadAvailableCoders(); + console.log(this.data); // Load coders if we're in edit mode and have a job ID if (this.data.isEdit && this.data.codingJob?.id) { this.loadCoders(this.data.codingJob.id); } } + /** + * Loads all available coders in the workspace for selection + */ + loadAvailableCoders(): void { + this.isLoadingAvailableCoders = true; + + this.coderService.getCoders().subscribe({ + next: coders => { + this.availableCoders = coders; + this.isLoadingAvailableCoders = false; + }, + error: () => { + this.isLoadingAvailableCoders = false; + } + }); + } + /** * Loads coders assigned to the current job * @param jobId The ID of the job @@ -129,6 +152,8 @@ export class CodingJobDialogComponent implements OnInit { next: coders => { this.coders = coders; this.isLoadingCoders = false; + // Pre-select the assigned coders + this.selectedCoders = new SelectionModel(true, coders); }, error: () => { this.isLoadingCoders = false; @@ -154,12 +179,7 @@ export class CodingJobDialogComponent implements OnInit { } } - loadVariableAnalysisItems( - page: number = 1, - limit: number = 10, - unitNameFilter?: string, - variableIdFilter?: string - ): void { + loadCodingIncompleteVariables(unitNameFilter?: string): void { this.isLoadingVariableAnalysis = true; const workspaceId = this.appService.selectedWorkspaceId; @@ -168,34 +188,13 @@ export class CodingJobDialogComponent implements OnInit { return; } - this.backendService.getVariableAnalysis( + this.backendService.getCodingIncompleteVariables( workspaceId, - page, - limit, - unitNameFilter || undefined, - variableIdFilter || undefined + unitNameFilter || undefined ).subscribe({ - next: response => { - // Convert variable analysis items to variable bundles - this.variableAnalysisItems = response.data; - - // Create unique variables from the items - const uniqueVariables = new Map(); - - this.variableAnalysisItems.forEach(item => { - const key = `${item.unitId}|${item.variableId}`; - if (!uniqueVariables.has(key)) { - uniqueVariables.set(key, { - unitName: item.unitId, - variableId: item.variableId - }); - } - }); - - this.variables = Array.from(uniqueVariables.values()); + next: variables => { + this.variables = variables; this.dataSource.data = this.variables; - - // Pre-select variables that were already selected if (this.data.codingJob?.variables) { this.data.codingJob.variables.forEach(variable => { const foundVariable = this.variables.find( @@ -207,8 +206,7 @@ export class CodingJobDialogComponent implements OnInit { }); } - this.totalVariableAnalysisRecords = response.total; - this.variableAnalysisPageIndex = page - 1; + this.totalVariableAnalysisRecords = variables.length; this.isLoadingVariableAnalysis = false; }, error: () => { @@ -239,18 +237,13 @@ export class CodingJobDialogComponent implements OnInit { } } - onPageChange(event: PageEvent): void { - // Preserve filters when changing pages - this.loadVariableAnalysisItems( - event.pageIndex + 1, - event.pageSize, - this.unitNameFilter || undefined, - this.variableIdFilter || undefined - ); + onPageChange(): void { + // Pagination not needed for CODING_INCOMPLETE variables + // This method can be removed or kept for future use } applyFilter(): void { - this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize, this.unitNameFilter, this.variableIdFilter); + this.loadCodingIncompleteVariables(this.unitNameFilter); } applyBundleFilter(): void { @@ -264,7 +257,7 @@ export class CodingJobDialogComponent implements OnInit { clearFilters(): void { this.unitNameFilter = ''; this.variableIdFilter = ''; - this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + this.loadCodingIncompleteVariables(); } clearBundleFilter(): void { @@ -279,6 +272,35 @@ export class CodingJobDialogComponent implements OnInit { return numSelected === numRows; } + /** + * Check if a variable was originally assigned to this coding job + * @param variable The variable to check + * @returns true if the variable was originally assigned to this job + */ + isVariableOriginallyAssigned(variable: Variable): boolean { + if (!this.data.codingJob?.variables) { + return false; + } + + return this.data.codingJob.variables.some( + originalVar => originalVar.unitName === variable.unitName && originalVar.variableId === variable.variableId + ); + } + + /** + * Check if a variable bundle was originally assigned to this coding job + * @param bundle The variable bundle to check + * @returns true if the bundle was originally assigned to this job + */ + isBundleOriginallyAssigned(bundle: VariableBundle): boolean { + if (!this.data.codingJob?.variableBundles) { + return false; + } + + return this.data.codingJob.variableBundles.some( + originalBundle => originalBundle.id === bundle.id + ); + } /** Gets the number of variables in a bundle */ getVariableCount(bundle: VariableBundle): number { @@ -301,6 +323,21 @@ export class CodingJobDialogComponent implements OnInit { } } + /** Whether all coders are selected. */ + isAllCodersSelected(): boolean { + const numSelected = this.selectedCoders.selected.length; + const numRows = this.availableCoders.length; + return numSelected === numRows && numRows > 0; + } + + /** Selects all coders if they are not all selected; otherwise clear selection. */ + masterCoderToggle(): void { + if (this.isAllCodersSelected()) { + this.selectedCoders.clear(); + } else { + this.availableCoders.forEach(coder => this.selectedCoders.select(coder)); + } + } onSubmit(): void { if (this.codingJobForm.invalid) { @@ -321,9 +358,11 @@ export class CodingJobDialogComponent implements OnInit { ...this.codingJobForm.value, createdAt: this.data.codingJob?.createdAt || new Date(), updatedAt: new Date(), - assignedCoders: this.data.codingJob?.assignedCoders || [], + assignedCoders: this.selectedCoders.selected.map(coder => coder.id), variables: this.selectedVariables.selected, - variableBundles: this.selectedVariableBundles.selected + variableBundles: this.selectedVariableBundles.selected, + assignedVariables: this.selectedVariables.selected, + assignedVariableBundles: this.selectedVariableBundles.selected }; // If we're editing an existing coding job diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts index e7e2eec1c..d66d3a8f3 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts @@ -21,16 +21,12 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatAnchor, MatButton } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { DatePipe, NgClass } from '@angular/common'; -import { of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; import { AppService } from '../../../services/app.service'; import { BackendService } from '../../../services/backend.service'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; import { CodingJob, Variable, VariableBundle } from '../../models/coding-job.model'; import { CodingJobDialogComponent } from '../coding-job-dialog/coding-job-dialog.component'; import { ConfirmDialogComponent } from '../../../shared/confirm-dialog/confirm-dialog.component'; -import { Coder } from '../../models/coder.model'; -import { CoderService } from '../../services/coder.service'; @Component({ selector: 'coding-box-coding-jobs', @@ -67,12 +63,9 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { backendService = inject(BackendService); private snackBar = inject(MatSnackBar); private dialog = inject(MatDialog); - private coderService = inject(CoderService); - // Cache for storing coder names by job ID private coderNamesByJobId = new Map(); - // Cache for storing job details (variables and variable bundles) private jobDetailsCache = new Map(); displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'assignedCoders', 'variables', 'variableBundles', 'createdAt', 'updatedAt']; @@ -80,37 +73,6 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { selection = new SelectionModel(true, []); isLoading = false; - // Sample data for demonstration - sampleData: CodingJob[] = [ - { - id: 1, - name: 'Kodierjob 1', - description: 'Beschreibung für Kodierjob 1', - status: 'active', - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-15'), - assignedCoders: [1, 2] - }, - { - id: 2, - name: 'Kodierjob 2', - description: 'Beschreibung für Kodierjob 2', - status: 'completed', - createdAt: new Date('2023-02-01'), - updatedAt: new Date('2023-02-15'), - assignedCoders: [3] - }, - { - id: 3, - name: 'Kodierjob 3', - description: 'Beschreibung für Kodierjob 3', - status: 'pending', - createdAt: new Date('2023-03-01'), - updatedAt: new Date('2023-03-15'), - assignedCoders: [] - } - ]; - @ViewChild(MatSort) sort!: MatSort; ngOnInit(): void { @@ -132,109 +94,32 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { this.backendService.getCodingJobs(workspaceId).subscribe({ next: response => { - // Convert string dates to Date objects - const processedData = response.data.map(job => ({ - ...job, - createdAt: job.createdAt ? new Date(job.createdAt) : new Date(), - updatedAt: job.updatedAt ? new Date(job.updatedAt) : new Date() - })); + this.coderNamesByJobId.clear(); + const processedData = response.data.map(job => { + if (job.assignedCoders && job.assignedCoders.length > 0) { + this.coderNamesByJobId.set(job.id, `${job.assignedCoders.length} Kodierer`); + } else { + this.coderNamesByJobId.set(job.id, 'Keine'); + } + + return { + ...job, + createdAt: job.createdAt ? new Date(job.createdAt) : new Date(), + updatedAt: job.updatedAt ? new Date(job.updatedAt) : new Date() + }; + }); this.dataSource.data = processedData; - // Clear the cache when loading new data this.jobDetailsCache.clear(); this.isLoading = false; - - // Prefetch details for visible jobs - this.prefetchJobDetails(); }, - error: error => { - console.error('Error loading coding jobs:', error); + error: () => { this.snackBar.open('Fehler beim Laden der Kodierjobs', 'Schließen', { duration: 3000 }); this.isLoading = false; } }); } - /** - * Prefetches details for visible jobs to improve user experience - */ - private prefetchJobDetails(): void { - const workspaceId = this.appService.selectedWorkspaceId; - if (!workspaceId) { - return; - } - - // Get the first few jobs to prefetch (limit to avoid too many requests) - const jobsToFetch = this.dataSource.data.slice(0, 5); - - // Fetch details for each job - jobsToFetch.forEach(job => { - this.fetchJobDetails(job.id); - }); - } - - /** - * Fetches detailed information for a coding job - * @param jobId The ID of the job to fetch details for - */ - private fetchJobDetails(jobId: number): void { - // Check if we already have the details in cache - if (this.jobDetailsCache.has(jobId)) { - return; - } - - const workspaceId = this.appService.selectedWorkspaceId; - if (!workspaceId) { - return; - } - - // Fetch the job details - this.backendService.getCodingJob(workspaceId, jobId) - .pipe( - catchError(error => { - console.error(`Error fetching details for job ${jobId}:`, error); - return of(null); - }) - ) - .subscribe(job => { - if (job) { - // Convert dates to Date objects - if (job.createdAt) { - job.createdAt = new Date(job.createdAt); - } - if (job.updatedAt) { - job.updatedAt = new Date(job.updatedAt); - } - - // Convert dates in variable bundles if they exist - if (job.variableBundles) { - job.variableBundles = job.variableBundles.map(bundle => ({ - ...bundle, - createdAt: bundle.createdAt ? new Date(bundle.createdAt) : new Date(), - updatedAt: bundle.updatedAt ? new Date(bundle.updatedAt) : new Date() - })); - } - - // Store the details in cache - this.jobDetailsCache.set(jobId, { - variables: job.variables, - variableBundles: job.variableBundles - }); - - // Update the job in the data source to ensure dates are formatted correctly - const dataIndex = this.dataSource.data.findIndex(item => item.id === jobId); - if (dataIndex >= 0) { - const updatedData = [...this.dataSource.data]; - updatedData[dataIndex] = { - ...updatedData[dataIndex], - ...job - }; - this.dataSource.data = updatedData; - } - } - }); - } - applyFilter(filterValue: string): void { this.dataSource.filter = filterValue.trim().toLowerCase(); } @@ -258,7 +143,6 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } selectRow(row: CodingJob, event?: MouseEvent): void { - // Prevent toggling selection when clicking on checkboxes if (event && event.target instanceof Element) { const target = event.target as Element; if (target.tagName === 'MAT-CHECKBOX' || @@ -269,89 +153,51 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } this.selection.toggle(row); - - // Fetch job details when a row is selected - if (this.selection.isSelected(row)) { - this.fetchJobDetails(row.id); - } } - /** - * Gets the variables assigned to a coding job - * @param job The coding job - * @returns A formatted string of variable IDs or a loading message - */ getVariables(job: CodingJob): string { - // Try to get from the job object first - if (job.variables && job.variables.length > 0) { - return this.formatVariables(job.variables); - } - - // Try to get from cache - const cachedDetails = this.jobDetailsCache.get(job.id); - if (cachedDetails && cachedDetails.variables && cachedDetails.variables.length > 0) { - return this.formatVariables(cachedDetails.variables); + if (job.assignedVariables && job.assignedVariables.length > 0) { + return this.formatAssignedVariables(job.assignedVariables); } - - // If not in cache, fetch the details - this.fetchJobDetails(job.id); - return 'Wird geladen...'; + return 'Keine Variablen'; } - /** - * Gets the variable bundles assigned to a coding job - * @param job The coding job - * @returns A formatted string of variable bundle names or a loading message - */ getVariableBundles(job: CodingJob): string { - // Try to get from the job object first - if (job.variableBundles && job.variableBundles.length > 0) { - return this.formatVariableBundles(job.variableBundles); - } + if (job.assignedVariableBundles && job.assignedVariableBundles.length > 0) { + const count = job.assignedVariableBundles.length; + const maxToShow = 2; + const bundleNames = job.assignedVariableBundles.map(b => b.name); - // Try to get from cache - const cachedDetails = this.jobDetailsCache.get(job.id); - if (cachedDetails && cachedDetails.variableBundles && cachedDetails.variableBundles.length > 0) { - return this.formatVariableBundles(cachedDetails.variableBundles); + if (bundleNames.length <= maxToShow) { + return `${count} (${bundleNames.join(', ')})`; + } + + const preview = bundleNames.slice(0, maxToShow).join(', '); + return `${count} (${preview}, +${count - maxToShow} weitere)`; } - // If not in cache, fetch the details - this.fetchJobDetails(job.id); - return 'Wird geladen...'; + return 'Keine Variablen-Bundles'; } - /** - * Formats variables for display - * @param variables The variables to format - * @returns A formatted string of variable IDs - */ - private formatVariables(variables: Variable[]): string { - if (!variables || variables.length === 0) { + private formatAssignedVariables(assignedVariables: Variable[]): string { + if (!assignedVariables || assignedVariables.length === 0) { return 'Keine Variablen'; } - // Limit the number of variables shown to avoid overflow const maxToShow = 3; - const variableIds = variables.map(v => v.variableId); + const variableNames = assignedVariables.map(v => `${v.unitName}_${v.variableId}`); - if (variableIds.length <= maxToShow) { - return variableIds.join(', '); + if (variableNames.length <= maxToShow) { + return variableNames.join(', '); } - return `${variableIds.slice(0, maxToShow).join(', ')} +${variableIds.length - maxToShow} weitere`; + return `${variableNames.slice(0, maxToShow).join(', ')} +${variableNames.length - maxToShow} weitere`; } - /** - * Formats variable bundles for display - * @param bundles The variable bundles to format - * @returns A formatted string of variable bundle names - */ private formatVariableBundles(bundles: VariableBundle[]): string { if (!bundles || bundles.length === 0) { return 'Keine Variablenbündel'; } - - // Limit the number of bundles shown to avoid overflow const maxToShow = 3; const bundleNames = bundles.map(b => b.name); @@ -362,44 +208,38 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { return `${bundleNames.slice(0, maxToShow).join(', ')} +${bundleNames.length - maxToShow} weitere`; } - /** - * Gets the full list of variables for a tooltip - * @param job The coding job - * @returns A formatted string of all variable IDs - */ getFullVariables(job: CodingJob): string { - // Try to get from the job object first + if (job.assignedVariables && job.assignedVariables.length > 0) { + const variableNames = job.assignedVariables.map(v => `${v.unitName}_${v.variableId}`); + return `Variablen (${job.assignedVariables.length}): ${variableNames.join(', ')}`; + } if (job.variables && job.variables.length > 0) { - return job.variables.map(v => v.variableId).join(', '); + return job.variables.map(v => `${v.unitName}_${v.variableId}`).join(', '); } - - // Try to get from cache const cachedDetails = this.jobDetailsCache.get(job.id); if (cachedDetails && cachedDetails.variables && cachedDetails.variables.length > 0) { - return cachedDetails.variables.map(v => v.variableId).join(', '); + return cachedDetails.variables.map(v => `${v.unitName}_${v.variableId}`).join(', '); } - return 'Keine Variablen'; + return 'Keine Variablen zugewiesen'; } - /** - * Gets the full list of variable bundles for a tooltip - * @param job The coding job - * @returns A formatted string of all variable bundle names - */ getFullVariableBundles(job: CodingJob): string { - // Try to get from the job object first + if (job.assignedVariableBundles && job.assignedVariableBundles.length > 0) { + const bundleNames = job.assignedVariableBundles.map(b => b.name); + return `Variablen-Bündel (${job.assignedVariableBundles.length}): ${bundleNames.join(', ')}`; + } + if (job.variableBundles && job.variableBundles.length > 0) { return job.variableBundles.map(b => b.name).join(', '); } - // Try to get from cache const cachedDetails = this.jobDetailsCache.get(job.id); if (cachedDetails && cachedDetails.variableBundles && cachedDetails.variableBundles.length > 0) { return cachedDetails.variableBundles.map(b => b.name).join(', '); } - return 'Keine Variablenbündel'; + return 'Keine Variablen-Bündel zugewiesen'; } createCodingJob(): void { @@ -473,9 +313,6 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } } - /** - * Gets the next available ID for a new coding job - */ private getNextId(): number { const jobs = this.dataSource.data; return jobs.length > 0 ? @@ -488,7 +325,6 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { const count = this.selection.selected.length; const jobNames = this.selection.selected.map(job => job.name).join(', '); - // Confirm deletion using Angular Material dialog const confirmMessage = count === 1 ? `Möchten Sie den Kodierjob "${jobNames}" wirklich löschen?` : `Möchten Sie ${count} Kodierjobs wirklich löschen?`; @@ -511,11 +347,9 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { return; } - // Track deletion progress let successCount = 0; let errorCount = 0; - // Process each selected job this.selection.selected.forEach(job => { this.backendService.deleteCodingJob(workspaceId, job.id).subscribe({ next: response => { @@ -537,12 +371,9 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { this.snackBar.open(`Fehler beim Löschen von Kodierjob "${job.name}"`, 'Schließen', { duration: 3000 }); } }, - error: error => { + error: () => { errorCount += 1; - console.error(`Error deleting coding job ${job.id}:`, error); this.snackBar.open(`Fehler beim Löschen von Kodierjob "${job.name}"`, 'Schließen', { duration: 3000 }); - - // If all jobs have been processed, refresh the list if (successCount + errorCount === this.selection.selected.length) { this.loadCodingJobs(); } @@ -587,68 +418,34 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } } - /** - * Gets the names of coders assigned to a job (truncated if too many) - * @param job The coding job - */ getAssignedCoderNames(job: CodingJob): string { - if (!job.assignedCoders || job.assignedCoders.length === 0) { - return 'Keine'; - } + if (this.coderNamesByJobId.has(job.id)) { + const coderNames = this.coderNamesByJobId.get(job.id)!; - // Store coder names for this job if we've already fetched them - if (!this.coderNamesByJobId.has(job.id)) { - // Fetch coders assigned to this job - this.coderService.getCodersByJobId(job.id).subscribe({ - next: (coders: Coder[]) => { - if (coders.length > 0) { - // Store the formatted names for this job - const coderNames = coders.map(coder => coder.displayName || coder.name).join(', '); - this.coderNamesByJobId.set(job.id, coderNames); - - // Refresh the data source to trigger UI update - const currentData = [...this.dataSource.data]; - this.dataSource.data = currentData; - } else { - this.coderNamesByJobId.set(job.id, 'Keine'); - } - }, - error: () => { - this.coderNamesByJobId.set(job.id, `${job.assignedCoders.length} Kodierer`); - } - }); + if (coderNames !== 'Keine' && coderNames.includes(',') && job.assignedCoders && job.assignedCoders.length > 2) { + const namesList = coderNames.split(', '); + return `${namesList[0]}, ${namesList[1]} +${job.assignedCoders.length - 2} weitere`; + } - // Return a loading indicator while we fetch the names - return 'Lade Kodierer...'; + return coderNames; } - // Get the cached coder names for this job - const coderNames = this.coderNamesByJobId.get(job.id) || `${job.assignedCoders.length} Kodierer`; - - // Truncate the list if it's too long (more than 2 coders) - if (coderNames !== 'Keine' && coderNames !== 'Lade Kodierer...' && job.assignedCoders.length > 2) { - const namesList = coderNames.split(', '); - return `${namesList[0]}, ${namesList[1]} +${job.assignedCoders.length - 2} weitere`; + if (!job.assignedCoders || job.assignedCoders.length === 0) { + return 'Keine'; } - return coderNames; + return `${job.assignedCoders.length} Kodierer`; } - /** - * Gets the full list of coder names for the tooltip - * @param job The coding job - */ getFullCoderNames(job: CodingJob): string { - if (!job.assignedCoders || job.assignedCoders.length === 0) { - return 'Keine Kodierer zugewiesen'; + if (this.coderNamesByJobId.has(job.id)) { + return this.coderNamesByJobId.get(job.id) || `${job.assignedCoders?.length || 0} Kodierer`; } - // If we haven't fetched the names yet, show a loading message - if (!this.coderNamesByJobId.has(job.id)) { - return 'Lade Kodierer...'; + if (!job.assignedCoders || job.assignedCoders.length === 0) { + return 'Keine Kodierer zugewiesen'; } - // Return the full list of coder names - return this.coderNamesByJobId.get(job.id) || `${job.assignedCoders.length} Kodierer`; + return `${job.assignedCoders.length} Kodierer`; } } diff --git a/apps/frontend/src/app/coding/models/coding-job.model.ts b/apps/frontend/src/app/coding/models/coding-job.model.ts index bdd901c5b..1fc519a7b 100644 --- a/apps/frontend/src/app/coding/models/coding-job.model.ts +++ b/apps/frontend/src/app/coding/models/coding-job.model.ts @@ -6,9 +6,11 @@ export interface CodingJob { createdAt: Date; updatedAt: Date; assignedCoders: number[]; + assignedVariables?: Variable[]; + assignedVariableBundles?: VariableBundle[]; variables?: Variable[]; variableBundles?: VariableBundle[]; - variableBundleIds?: number[]; // Added for backend compatibility + variableBundleIds?: number[]; } export interface Variable { diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 2fce6aa02..a51e917c9 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -812,11 +812,6 @@ export class BackendService { return this.http.get>(url); } - /** - * Get replay error statistics - * @param workspaceId The ID of the workspace - * @returns Observable of replay error statistics - */ getReplayErrorStatistics(workspaceId: number): Observable<{ successRate: number; totalReplays: number; @@ -834,41 +829,21 @@ export class BackendService { }>(url); } - /** - * Get failure distribution by unit - * @param workspaceId The ID of the workspace - * @returns Observable of failure distribution by unit - */ getFailureDistributionByUnit(workspaceId: number): Observable> { const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/unit`; return this.http.get>(url); } - /** - * Get failure distribution by day - * @param workspaceId The ID of the workspace - * @returns Observable of failure distribution by day - */ getFailureDistributionByDay(workspaceId: number): Observable> { const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/day`; return this.http.get>(url); } - /** - * Get failure distribution by hour - * @param workspaceId The ID of the workspace - * @returns Observable of failure distribution by hour - */ getFailureDistributionByHour(workspaceId: number): Observable> { const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/hour`; return this.http.get>(url); } - /** - * Get all variable bundles for a workspace - * @param workspaceId The ID of the workspace - * @returns Observable of variable bundles - */ getVariableBundles(workspaceId: number): Observable { const url = `${this.serverUrl}admin/workspace/${workspaceId}/variable-bundle`; return this.http.get>(url) @@ -877,13 +852,6 @@ export class BackendService { ); } - /** - * Get all coding jobs for a workspace - * @param workspaceId The ID of the workspace - * @param page The page number (1-based) - * @param limit The number of items per page - * @returns Observable of paginated coding jobs - */ getCodingJobs( workspaceId: number, page: number = 1, @@ -896,35 +864,11 @@ export class BackendService { return this.http.get>(url, { params }); } - /** - * Get a coding job by ID - * @param workspaceId The ID of the workspace - * @param codingJobId The ID of the coding job - * @returns Observable of the coding job - */ - getCodingJob(workspaceId: number, codingJobId: number): Observable { - const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; - return this.http.get(url); - } - - /** - * Create a new coding job - * @param workspaceId The ID of the workspace - * @param codingJob The coding job to create - * @returns Observable of the created coding job - */ createCodingJob(workspaceId: number, codingJob: Omit): Observable { const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job`; return this.http.post(url, codingJob); } - /** - * Update a coding job - * @param workspaceId The ID of the workspace - * @param codingJobId The ID of the coding job - * @param codingJob The coding job data to update - * @returns Observable of the updated coding job - */ updateCodingJob( workspaceId: number, codingJobId: number, @@ -934,30 +878,20 @@ export class BackendService { return this.http.put(url, codingJob); } - /** - * Delete a coding job - * @param workspaceId The ID of the workspace - * @param codingJobId The ID of the coding job - * @returns Observable of the delete result - */ deleteCodingJob(workspaceId: number, codingJobId: number): Observable<{ success: boolean }> { const url = `${this.serverUrl}/wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; return this.http.delete<{ success: boolean }>(url); } - /** - * Assign coders to a coding job - * @param workspaceId The ID of the workspace - * @param codingJobId The ID of the coding job - * @param userIds Array of user IDs to assign as coders - * @returns Observable of the assignment result - */ - assignCodersToCodingJob( + getCodingIncompleteVariables( workspaceId: number, - codingJobId: number, - userIds: number[] - ): Observable<{ success: boolean }> { - const url = `${this.serverUrl}/wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/assign-coders`; - return this.http.post<{ success: boolean }>(url, { userIds }); + unitName?: string + ): Observable<{ unitName: string; variableId: string }[]> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding/incomplete-variables`; + let params = new HttpParams(); + if (unitName) { + params = params.set('unitName', unitName); + } + return this.http.get<{ unitName: string; variableId: string }[]>(url, { params }); } } From 397c7a21035952dcb89f4febe8c543611ee5f0d6 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:57:52 +0200 Subject: [PATCH 7/8] Store replay statistics for non-logged-in persons --- .../app/admin/replay-statistics/replay-statistics.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts index 91c3244c7..f173d9840 100644 --- a/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts +++ b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts @@ -31,7 +31,6 @@ export class ReplayStatisticsController { @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) @ApiResponse({ status: 201, description: 'Replay statistics stored successfully' }) @Post() - @UseGuards(JwtAuthGuard) async storeReplayStatistics( @Param('workspace_id') workspaceId: string, @Body() data: { From c8707ae524832ee61a28ac98ed32deac961f969b Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:59:05 +0200 Subject: [PATCH 8/8] Set version to 0.13.0 --- .../src/app/database/services/workspace-coding.service.ts | 2 +- apps/frontend/src/app/components/home/home.component.html | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 39e813a58..54d582235 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -198,7 +198,7 @@ export class WorkspaceCodingService { let bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); if (!bullJob) { - bullJob = await this.jobQueueService.getCodingStatisticsJob(jobId) as any; + bullJob = await this.jobQueueService.getCodingStatisticsJob(jobId) as never; } if (bullJob) { diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 2e42de375..6dc9b9f54 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.12.1'" + [appVersion]="'0.13.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 820c78dce..139c95b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.12.1", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.12.1", + "version": "0.13.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index d864b60d1..fa081155e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.12.1", + "version": "0.13.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {