diff --git a/api-dto/workspaces/workspace-user-dto.ts b/api-dto/workspaces/workspace-user-dto.ts index a307f4c4b..9b7bf8119 100644 --- a/api-dto/workspaces/workspace-user-dto.ts +++ b/api-dto/workspaces/workspace-user-dto.ts @@ -8,5 +8,5 @@ export class WorkspaceUserDto { userId!: number; @ApiProperty({ nullable: true }) - accessLevel!: string | null; + accessLevel!: number | null; } diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index e8e27a367..cf7050eaf 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -26,13 +26,16 @@ import { BookletInfoService } from '../database/services/booklet-info.service'; import { UnitInfoService } from '../database/services/unit-info.service'; import FileUpload from '../database/entities/file_upload.entity'; import { ReplayStatisticsController } from './replay-statistics/replay-statistics.controller'; +import { VariableBundleModule } from './variable-bundle/variable-bundle.module'; +import { VariableBundleController } from './variable-bundle/variable-bundle.controller'; @Module({ imports: [ DatabaseModule, AuthModule, HttpModule, - TypeOrmModule.forFeature([FileUpload]) + TypeOrmModule.forFeature([FileUpload]), + VariableBundleModule ], controllers: [ UsersController, @@ -54,7 +57,8 @@ import { ReplayStatisticsController } from './replay-statistics/replay-statistic BookletInfoController, UnitInfoController, MissingsProfilesController, - ReplayStatisticsController + ReplayStatisticsController, + VariableBundleController ], providers: [ BookletInfoService, diff --git a/apps/backend/src/app/admin/variable-bundle/dto/create-variable-bundle.dto.ts b/apps/backend/src/app/admin/variable-bundle/dto/create-variable-bundle.dto.ts new file mode 100644 index 000000000..ec8c28a24 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/dto/create-variable-bundle.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, IsNotEmpty, IsOptional, IsString +} from 'class-validator'; +import { VariableDto } from './variable.dto'; + +export class CreateVariableBundleDto { + @ApiProperty({ + description: 'The name of the variable bundle', + example: 'Mathematical Skills' + }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ + description: 'The description of the variable bundle', + example: 'Variables for assessing mathematical skills', + required: false + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'The variables in the bundle', + type: [VariableDto], + default: [] + }) + @IsArray() + variables: VariableDto[]; +} diff --git a/apps/backend/src/app/admin/variable-bundle/dto/update-variable-bundle.dto.ts b/apps/backend/src/app/admin/variable-bundle/dto/update-variable-bundle.dto.ts new file mode 100644 index 000000000..35272bcb0 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/dto/update-variable-bundle.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; +import { VariableDto } from './variable.dto'; + +export class UpdateVariableBundleDto { + @ApiProperty({ + description: 'The name of the variable bundle', + example: 'Mathematical Skills', + required: false + }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ + description: 'The description of the variable bundle', + example: 'Variables for assessing mathematical skills', + required: false + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'The variables in the bundle', + type: [VariableDto], + required: false + }) + @IsOptional() + @IsArray() + variables?: VariableDto[]; +} diff --git a/apps/backend/src/app/admin/variable-bundle/dto/variable-bundle.dto.ts b/apps/backend/src/app/admin/variable-bundle/dto/variable-bundle.dto.ts new file mode 100644 index 000000000..017762ebd --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/dto/variable-bundle.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { VariableBundle } from '../../../database/entities/variable-bundle.entity'; +import { VariableDto } from './variable.dto'; + +export class VariableBundleDto { + @ApiProperty({ + description: 'The ID of the variable bundle', + example: 1 + }) + id: number; + + @ApiProperty({ + description: 'The ID of the workspace', + example: 1 + }) + workspace_id: number; + + @ApiProperty({ + description: 'The name of the variable bundle', + example: 'Mathematical Skills' + }) + name: string; + + @ApiProperty({ + description: 'The description of the variable bundle', + example: 'Variables for assessing mathematical skills', + required: false + }) + description?: string; + + @ApiProperty({ + description: 'The variables in the bundle', + type: [VariableDto] + }) + variables: VariableDto[]; + + @ApiProperty({ + description: 'The date the variable bundle was created', + example: '2025-08-04T13:58:00.000Z' + }) + created_at: Date; + + @ApiProperty({ + description: 'The date the variable bundle was last updated', + example: '2025-08-04T13:58:00.000Z' + }) + updated_at: Date; + + /** + * Static method to create a DTO from an entity + */ + static fromEntity(entity: VariableBundle): VariableBundleDto { + const dto = new VariableBundleDto(); + dto.id = entity.id; + dto.workspace_id = entity.workspace_id; + dto.name = entity.name; + dto.description = entity.description; + // Transform each variable to a VariableDto + dto.variables = entity.variables.map(v => { + const variableDto = new VariableDto(); + variableDto.unitName = v.unitName; + variableDto.variableId = v.variableId; + return variableDto; + }); + dto.created_at = entity.created_at; + dto.updated_at = entity.updated_at; + return dto; + } +} diff --git a/apps/backend/src/app/admin/variable-bundle/dto/variable.dto.ts b/apps/backend/src/app/admin/variable-bundle/dto/variable.dto.ts new file mode 100644 index 000000000..dca0d8518 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/dto/variable.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class VariableDto { + @ApiProperty({ + description: 'The unit name of the variable', + example: 'math101' + }) + unitName: string; + + @ApiProperty({ + description: 'The variable ID', + example: 'addition' + }) + variableId: string; +} diff --git a/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts b/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts new file mode 100644 index 000000000..eda892bd4 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts @@ -0,0 +1,350 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from '../workspace/workspace.guard'; +import { WorkspaceId } from '../workspace/workspace.decorator'; +import { VariableBundleService } from '../../database/services/variable-bundle.service'; +import { VariableBundleDto } from './dto/variable-bundle.dto'; +import { CreateVariableBundleDto } from './dto/create-variable-bundle.dto'; +import { UpdateVariableBundleDto } from './dto/update-variable-bundle.dto'; +import { VariableDto } from './dto/variable.dto'; + +@ApiTags('Variable Bundles') +@Controller('admin/workspace/:workspace_id/variable-bundle') +export class VariableBundleController { + constructor(private readonly variableBundleService: VariableBundleService) {} + + @Get() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all variable bundles for a workspace', + description: 'Retrieves all variable bundles for a workspace' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiOkResponse({ + description: 'The variable bundles have been successfully retrieved.', + type: [VariableBundleDto] + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + @ApiNotFoundResponse({ + description: 'Workspace not found.' + }) + async getVariableBundles(@WorkspaceId() workspaceId: number): Promise { + try { + const variableBundles = await this.variableBundleService.getVariableBundles(workspaceId); + return variableBundles.map(bundle => VariableBundleDto.fromEntity(bundle)); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve variable bundles: ${error.message}`); + } + } + + @Get(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get a variable bundle by ID', + description: 'Retrieves a variable bundle 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 variable bundle' + }) + @ApiOkResponse({ + description: 'The variable bundle has been successfully retrieved.', + type: VariableBundleDto + }) + @ApiNotFoundResponse({ + description: 'Variable bundle not found.' + }) + async getVariableBundle( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise { + try { + const variableBundle = await this.variableBundleService.getVariableBundle(id, workspaceId); + return VariableBundleDto.fromEntity(variableBundle); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to retrieve variable bundle: ${error.message}`); + } + } + + @Post() + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create a new variable bundle', + description: 'Creates a new variable bundle' + }) + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'The ID of the workspace' + }) + @ApiCreatedResponse({ + description: 'The variable bundle has been successfully created.', + type: VariableBundleDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async createVariableBundle( + @WorkspaceId() workspaceId: number, + @Body() createVariableBundleDto: CreateVariableBundleDto + ): Promise { + try { + const variableBundle = await this.variableBundleService.createVariableBundle( + workspaceId, + createVariableBundleDto + ); + return VariableBundleDto.fromEntity(variableBundle); + } catch (error) { + throw new BadRequestException(`Failed to create variable bundle: ${error.message}`); + } + } + + @Put(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a variable bundle', + description: 'Updates a variable bundle' + }) + @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 variable bundle' + }) + @ApiOkResponse({ + description: 'The variable bundle has been successfully updated.', + type: VariableBundleDto + }) + @ApiNotFoundResponse({ + description: 'Variable bundle not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async updateVariableBundle( + @WorkspaceId() workspaceId: number, + @Param('id') id: number, + @Body() updateVariableBundleDto: UpdateVariableBundleDto + ): Promise { + try { + const variableBundle = await this.variableBundleService.updateVariableBundle( + id, + workspaceId, + updateVariableBundleDto + ); + return VariableBundleDto.fromEntity(variableBundle); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to update variable bundle: ${error.message}`); + } + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a variable bundle', + description: 'Deletes a variable bundle' + }) + @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 variable bundle' + }) + @ApiOkResponse({ + description: 'The variable bundle has been successfully deleted.', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Variable bundle not found.' + }) + async deleteVariableBundle( + @WorkspaceId() workspaceId: number, + @Param('id') id: number + ): Promise<{ success: boolean }> { + try { + return await this.variableBundleService.deleteVariableBundle(id, workspaceId); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to delete variable bundle: ${error.message}`); + } + } + + @Post(':id/variables') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Add a variable to a variable bundle', + description: 'Adds a variable to a variable bundle' + }) + @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 variable bundle' + }) + @ApiOkResponse({ + description: 'The variable has been successfully added to the variable bundle.', + type: VariableBundleDto + }) + @ApiNotFoundResponse({ + description: 'Variable bundle not found.' + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async addVariableToBundle( + @WorkspaceId() workspaceId: number, + @Param('id') id: number, + @Body() variable: VariableDto + ): Promise { + try { + const variableBundle = await this.variableBundleService.addVariableToBundle( + id, + workspaceId, + variable + ); + return VariableBundleDto.fromEntity(variableBundle); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to add variable to variable bundle: ${error.message}`); + } + } + + @Delete(':id/variables/:unitName/:variableId') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Remove a variable from a variable bundle', + description: 'Removes a variable from a variable bundle' + }) + @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 variable bundle' + }) + @ApiParam({ + name: 'unitName', + type: String, + required: true, + description: 'The unit name of the variable' + }) + @ApiParam({ + name: 'variableId', + type: String, + required: true, + description: 'The variable ID' + }) + @ApiOkResponse({ + description: 'The variable has been successfully removed from the variable bundle.', + type: VariableBundleDto + }) + @ApiNotFoundResponse({ + description: 'Variable bundle not found.' + }) + async removeVariableFromBundle( + @WorkspaceId() workspaceId: number, + @Param('id') id: number, + @Param('unitName') unitName: string, + @Param('variableId') variableId: string + ): Promise { + try { + const variableBundle = await this.variableBundleService.removeVariableFromBundle( + id, + workspaceId, + unitName, + variableId + ); + return VariableBundleDto.fromEntity(variableBundle); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + throw new BadRequestException(`Failed to remove variable from variable bundle: ${error.message}`); + } + } +} diff --git a/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts b/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts new file mode 100644 index 000000000..3b90361b5 --- /dev/null +++ b/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VariableBundle } from '../../database/entities/variable-bundle.entity'; +import { VariableBundleService } from '../../database/services/variable-bundle.service'; +import { VariableBundleController } from './variable-bundle.controller'; +import { AuthModule } from '../../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([VariableBundle]), + AuthModule + ], + controllers: [VariableBundleController], + providers: [VariableBundleService], + exports: [VariableBundleService] +}) +export class VariableBundleModule {} diff --git a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts index 5f11a34bb..1504e4595 100644 --- a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts @@ -15,6 +15,7 @@ import { WorkspaceGuard } from './workspace.guard'; import { AuthService } from '../../auth/service/auth.service'; import WorkspaceUser from '../../database/entities/workspace_user.entity'; import { WorkspaceUsersService } from '../../database/services/workspace-users.service'; +import { WorkspaceId } from './workspace.decorator'; @ApiTags('Admin Workspace Users') @Controller('admin/workspace') @@ -173,4 +174,57 @@ export class WorkspaceUsersController { }; } } + + @Get(':workspace_id/coding-jobs/:job_id/coders') + @ApiTags('admin workspace users') + @ApiBearerAuth() + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'Unique identifier for the workspace' + }) + @ApiParam({ + name: 'job_id', + type: Number, + required: true, + description: 'Unique identifier for the coding job' + }) + @ApiOkResponse({ + description: 'List of coders assigned to the coding job retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/WorkspaceUser' } }, + total: { type: 'number' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Workspace or coding job not found, or no coders assigned to the job' + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findCodersByCodingJob( + @WorkspaceId() workspaceId: number, + @Param('job_id') jobId: number + ): Promise<{ data: WorkspaceUser[]; total: number }> { + try { + // In a real implementation, this would filter coders by the specific job ID + // For now, we'll return all coders for the workspace + const [coders, total] = await this.workspaceUsersService.findCoders(workspaceId); + + logger.log(`Retrieved ${total} coders for workspace ${workspaceId} and coding job ${jobId}`); + + return { + data: coders, + total + }; + } catch (error) { + logger.error(`Error retrieving coders for workspace ${workspaceId} and coding job ${jobId}`); + return { + data: [], + total: 0 + }; + } + } } diff --git a/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts b/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts deleted file mode 100644 index 8a2a80c27..000000000 --- a/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CacheService } from './cache.service'; -import Persons from '../database/entities/persons.entity'; -import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service'; - -@Injectable() -export class BookletCacheSchedulerService { - private readonly logger = new Logger(BookletCacheSchedulerService.name); - private readonly BOOKLET_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds - - constructor( - private readonly cacheService: CacheService, - private readonly workspaceTestResultsService: WorkspaceTestResultsService, - @InjectRepository(Persons) - private readonly personsRepository: Repository - ) {} - - /** - * Scheduled task to cache all test person booklets - * Runs every night at 3:00 AM (after the response cache scheduler) - */ - @Cron(CronExpression.EVERY_DAY_AT_3AM) - async cacheAllBooklets() { - this.logger.log('Starting nightly task to cache all test person booklets'); - - try { - // Get all workspaces with persons - const workspaces = await this.getWorkspacesWithPersons(); - - for (const workspace of workspaces) { - const workspaceId = workspace.workspace_id; - this.logger.log(`Caching booklets for workspace ${workspaceId}`); - - // Get all test persons in this workspace - const persons = await this.personsRepository.find({ - where: { workspace_id: workspaceId, consider: true } - }); - - for (const person of persons) { - try { - // Cache the booklet data for this person - await this.cachePersonBooklets(person.id, workspaceId); - } catch (error) { - this.logger.error(`Error caching booklets for person ID ${person.id} in workspace ${workspaceId}: ${error.message}`, error.stack); - } - } - } - - this.logger.log('Finished nightly caching of all test person booklets'); - } catch (error) { - this.logger.error(`Error in cacheAllBooklets: ${error.message}`, error.stack); - } - } - - /** - * Get all workspaces that have persons - */ - private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> { - return this.personsRepository - .createQueryBuilder('person') - .select('DISTINCT person.workspace_id', 'workspace_id') - .where('person.consider = :consider', { consider: true }) - .getRawMany(); - } - - /** - * Cache booklet data for a specific person - */ - private async cachePersonBooklets(personId: number, workspaceId: number): Promise { - const cacheKey = this.generateBookletCacheKey(workspaceId, personId); - - // Check if already in cache - const exists = await this.cacheService.exists(cacheKey); - if (exists) { - this.logger.debug(`Booklet data already in cache for person ID ${personId} in workspace ${workspaceId}`); - return; - } - - // Fetch and cache the booklet data - try { - const bookletData = await this.workspaceTestResultsService.findPersonTestResults(personId, workspaceId); - await this.cacheService.set(cacheKey, bookletData, this.BOOKLET_CACHE_TTL); - this.logger.debug(`Cached booklet data for person ID ${personId} in workspace ${workspaceId}`); - } catch (error) { - this.logger.error(`Error fetching booklet data for caching: ${error.message}`, error.stack); - throw error; - } - } - - /** - * Generate a cache key for booklet data - */ - private generateBookletCacheKey(workspaceId: number, personId: number): string { - return `booklets:${workspaceId}:${personId}`; - } -} diff --git a/apps/backend/src/app/cache/cache.module.ts b/apps/backend/src/app/cache/cache.module.ts index a1ce1966e..348b1ef16 100644 --- a/apps/backend/src/app/cache/cache.module.ts +++ b/apps/backend/src/app/cache/cache.module.ts @@ -5,7 +5,6 @@ import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CacheService } from './cache.service'; import { ResponseCacheSchedulerService } from './response-cache-scheduler.service'; -import { BookletCacheSchedulerService } from './booklet-cache-scheduler.service'; import Persons from '../database/entities/persons.entity'; import { Unit } from '../database/entities/unit.entity'; // eslint-disable-next-line import/no-cycle @@ -29,7 +28,7 @@ import { DatabaseModule } from '../database/database.module'; TypeOrmModule.forFeature([Persons, Unit]), forwardRef(() => DatabaseModule) ], - providers: [CacheService, ResponseCacheSchedulerService, BookletCacheSchedulerService], + providers: [CacheService, ResponseCacheSchedulerService], exports: [CacheService] }) export class CacheModule {} diff --git a/apps/backend/src/app/cache/cache.service.ts b/apps/backend/src/app/cache/cache.service.ts index f8bf632c2..db1c13048 100644 --- a/apps/backend/src/app/cache/cache.service.ts +++ b/apps/backend/src/app/cache/cache.service.ts @@ -5,8 +5,7 @@ import Redis from 'ioredis'; @Injectable() export class CacheService { private readonly logger = new Logger(CacheService.name); - private readonly DEFAULT_TTL = 3600; // 1 hour in seconds - + private readonly DEFAULT_TTL = 86400; // 24 Stunden in Sekunden constructor( @InjectRedis() private readonly redis: Redis ) {} diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts index 551c7bf5a..783f28e90 100644 --- a/apps/backend/src/app/cache/response-cache-scheduler.service.ts +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -20,48 +20,100 @@ export class ResponseCacheSchedulerService { private readonly unitRepository: Repository ) {} - /** - * Scheduled task to cache all possible replay URLs and their responses - * Runs every night at 2:00 AM - */ - @Cron(CronExpression.EVERY_DAY_AT_1AM) + @Cron(CronExpression.EVERY_DAY_AT_4AM) async cacheAllResponses() { this.logger.log('Starting nightly task to cache all responses'); + const startTime = Date.now(); try { // Get all workspaces with persons const workspaces = await this.getWorkspacesWithPersons(); + this.logger.log(`Found ${workspaces.length} workspaces with test persons`); + + // Process workspaces in parallel with a concurrency limit + const concurrencyLimit = 3; // Adjust based on system resources + const chunks = this.chunkArray(workspaces, concurrencyLimit); + + for (const workspaceChunk of chunks) { + await Promise.all( + workspaceChunk.map(workspace => this.processWorkspace(workspace.workspace_id)) + ); + } + + const duration = (Date.now() - startTime) / 1000; + this.logger.log(`Finished nightly caching of all responses in ${duration.toFixed(2)} seconds`); + } catch (error) { + this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack); + } + } + + /** + * Process a single workspace by caching all its responses + */ + private async processWorkspace(workspaceId: number): Promise { + try { + this.logger.log(`Processing workspace ${workspaceId}`); + const workspaceStartTime = Date.now(); + + // Get all test persons and their units in a single query + const personsWithUnits = await this.getPersonsWithUnits(workspaceId); + this.logger.log(`Found ${personsWithUnits.length} persons in workspace ${workspaceId}`); + + // Prepare all cache items to check + const cacheCheckItems: { workspaceId: number; connector: string; unitId: string; cacheKey: string }[] = []; + + for (const person of personsWithUnits) { + for (const unit of person.units) { + const connector = this.createConnector(person, unit.booklet.bookletinfo.name); + const cacheKey = this.cacheService.generateUnitResponseCacheKey(workspaceId, connector, unit.alias); + + cacheCheckItems.push({ + workspaceId, + connector, + unitId: unit.alias, + cacheKey + }); + } + } - for (const workspace of workspaces) { - const workspaceId = workspace.workspace_id; - this.logger.log(`Caching responses for workspace ${workspaceId}`); - - // Get all test persons in this workspace - const persons = await this.personsRepository.find({ - where: { workspace_id: workspaceId, consider: true } - }); - - for (const person of persons) { - // Get all units for this person - const units = await this.getUnitsForPerson(person.id); - - for (const unit of units) { - // Create the connector string (login@code@bookletId) - const connector = this.createConnector(person, unit.booklet.bookletinfo.name); - - try { - // Cache the response - await this.cacheResponse(workspaceId, connector, unit.alias); - } catch (error) { - this.logger.error(`Error caching response for workspace=${workspaceId}, testPerson=${connector}, unitId=${unit.alias}: ${error.message}`, error.stack); - } + // Check which items are already in cache (in batches) + const batchSize = 100; + const itemsToCache: typeof cacheCheckItems = []; + + for (let i = 0; i < cacheCheckItems.length; i += batchSize) { + const batch = cacheCheckItems.slice(i, i + batchSize); + const cacheKeys = batch.map(item => item.cacheKey); + + // Check multiple cache keys at once if Redis supports it + const existsResults = await Promise.all(cacheKeys.map(key => this.cacheService.exists(key))); + + for (let j = 0; j < batch.length; j++) { + if (!existsResults[j]) { + itemsToCache.push(batch[j]); } } } - this.logger.log('Finished nightly caching of all responses'); + this.logger.log(`Found ${itemsToCache.length} items that need caching in workspace ${workspaceId}`); + + // Process items that need caching in smaller parallel batches + const cacheBatchSize = 20; // Adjust based on system resources + const cacheBatches = this.chunkArray(itemsToCache, cacheBatchSize); + + for (const batch of cacheBatches) { + await Promise.all( + batch.map(item => this.cacheResponseWithRetry( + item.workspaceId, + item.connector, + item.unitId + )) + ); + } + + const duration = (Date.now() - workspaceStartTime) / 1000; + this.logger.log(`Finished processing workspace ${workspaceId} in ${duration.toFixed(2)} seconds`); } catch (error) { - this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack); + this.logger.error(`Error processing workspace ${workspaceId}: ${error.message}`, error.stack); } } @@ -76,18 +128,6 @@ export class ResponseCacheSchedulerService { .getRawMany(); } - /** - * Get all units for a person - */ - private async getUnitsForPerson(personId: number): Promise { - return this.unitRepository - .createQueryBuilder('unit') - .leftJoinAndSelect('unit.booklet', 'booklet') - .leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo') - .where('booklet.personid = :personId', { personId }) - .getMany(); - } - /** * Create a connector string for a person and booklet */ @@ -118,4 +158,75 @@ export class ResponseCacheSchedulerService { throw error; } } + + /** + * Cache a response with retry logic + */ + private async cacheResponseWithRetry( + workspaceId: number, + connector: string, + unitId: string, + retries = 2 + ): Promise { + try { + await this.cacheResponse(workspaceId, connector, unitId); + } catch (error) { + if (retries > 0) { + this.logger.warn(`Retrying cache operation for workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}. Retries left: ${retries}`); + await new Promise(resolve => { setTimeout(resolve, 1000); }); // Wait 1 second before retry + await this.cacheResponseWithRetry(workspaceId, connector, unitId, retries - 1); + } else { + this.logger.error(`Failed to cache response after retries: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + // Don't rethrow to avoid failing the entire batch + } + } + } + + /** + * Get all persons with their units for a workspace in a single optimized query + */ + private async getPersonsWithUnits(workspaceId: number): Promise<(Persons & { units: Unit[] })[]> { + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId, consider: true } + }); + + if (persons.length === 0) { + return []; + } + + const personIds = persons.map(person => person.id); + + const units = await this.unitRepository + .createQueryBuilder('unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + const unitsByPersonId = new Map(); + for (const unit of units) { + const personId = unit.booklet.personid; + if (!unitsByPersonId.has(personId)) { + unitsByPersonId.set(personId, []); + } + unitsByPersonId.get(personId).push(unit); + } + + // Attach units to each person + return persons.map(person => ({ + ...person, + units: unitsByPersonId.get(person.id) || [] + })); + } + + /** + * Split an array into chunks of specified size + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; + } } diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index c011c947b..a07e63a1a 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -47,6 +47,7 @@ import { ValidationTask } from './entities/validation-task.entity'; 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'; // eslint-disable-next-line import/no-cycle import { JobQueueModule } from '../job-queue/job-queue.module'; // eslint-disable-next-line import/no-cycle @@ -82,7 +83,7 @@ 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 + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics, VariableBundle ], synchronize: false }), @@ -113,7 +114,8 @@ import { CacheModule } from '../cache/cache.module'; VariableAnalysisJob, ValidationTask, Setting, - ReplayStatistics + ReplayStatistics, + VariableBundle ]) ], providers: [ diff --git a/apps/backend/src/app/database/entities/variable-bundle.entity.ts b/apps/backend/src/app/database/entities/variable-bundle.entity.ts new file mode 100644 index 000000000..3e7fe95ca --- /dev/null +++ b/apps/backend/src/app/database/entities/variable-bundle.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn +} from 'typeorm'; + +/** + * Entity for variable bundles + * A variable bundle is a collection of variables that can be used together + */ +@Entity({ name: 'variable_bundle' }) +export class VariableBundle { + @PrimaryGeneratedColumn() + id: number; + + @Column() + workspace_id: number; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + /** + * Array of variables in the bundle + * Each variable has a unitName and variableId + * Stored as a JSON array + */ + @Column({ type: 'jsonb' }) + variables: Array<{ unitName: string; variableId: string }>; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/apps/backend/src/app/database/services/variable-bundle.service.ts b/apps/backend/src/app/database/services/variable-bundle.service.ts new file mode 100644 index 000000000..6e1876179 --- /dev/null +++ b/apps/backend/src/app/database/services/variable-bundle.service.ts @@ -0,0 +1,160 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { VariableBundle } from '../entities/variable-bundle.entity'; + +/** + * Service for managing variable bundles + */ +@Injectable() +export class VariableBundleService { + constructor( + @InjectRepository(VariableBundle) + private variableBundleRepository: Repository + ) {} + + /** + * Get all variable bundles for a workspace + * @param workspaceId The ID of the workspace + * @returns Array of variable bundles + */ + async getVariableBundles(workspaceId: number): Promise { + return this.variableBundleRepository.find({ + where: { workspace_id: workspaceId }, + order: { created_at: 'DESC' } + }); + } + + /** + * Get a variable bundle by ID + * @param id The ID of the variable bundle + * @param workspaceId Optional workspace ID to filter by + * @returns The variable bundle + * @throws NotFoundException if the variable bundle is not found + */ + async getVariableBundle(id: number, workspaceId?: number): Promise { + const whereClause: { id: number; workspace_id?: number } = { id }; + + if (workspaceId !== undefined) { + whereClause.workspace_id = workspaceId; + } + + const variableBundle = await this.variableBundleRepository.findOne({ where: whereClause }); + if (!variableBundle) { + if (workspaceId !== undefined) { + throw new NotFoundException(`Variable bundle with ID ${id} not found in workspace ${workspaceId}`); + } else { + throw new NotFoundException(`Variable bundle with ID ${id} not found`); + } + } + return variableBundle; + } + + /** + * Create a new variable bundle + * @param workspaceId The ID of the workspace + * @param data The variable bundle data + * @returns The created variable bundle + */ + async createVariableBundle( + workspaceId: number, + data: Omit + ): Promise { + const variableBundle = this.variableBundleRepository.create({ + ...data, + workspace_id: workspaceId + }); + + return this.variableBundleRepository.save(variableBundle); + } + + /** + * Update a variable bundle + * @param id The ID of the variable bundle + * @param workspaceId The ID of the workspace + * @param data The variable bundle data to update + * @returns The updated variable bundle + * @throws NotFoundException if the variable bundle is not found + */ + async updateVariableBundle( + id: number, + workspaceId: number, + data: Partial> + ): Promise { + const variableBundle = await this.getVariableBundle(id, workspaceId); + + // Apply updates + Object.assign(variableBundle, data); + + // Save the variable bundle + return this.variableBundleRepository.save(variableBundle); + } + + /** + * Delete a variable bundle + * @param id The ID of the variable bundle + * @param workspaceId The ID of the workspace + * @returns Object with success flag + * @throws NotFoundException if the variable bundle is not found + */ + async deleteVariableBundle(id: number, workspaceId: number): Promise<{ success: boolean }> { + const variableBundle = await this.getVariableBundle(id, workspaceId); + + await this.variableBundleRepository.remove(variableBundle); + + return { success: true }; + } + + /** + * Add a variable to a variable bundle + * @param id The ID of the variable bundle + * @param workspaceId The ID of the workspace + * @param variable The variable to add + * @returns The updated variable bundle + * @throws NotFoundException if the variable bundle is not found + */ + async addVariableToBundle( + id: number, + workspaceId: number, + variable: { unitName: string; variableId: string } + ): Promise { + const variableBundle = await this.getVariableBundle(id, workspaceId); + + // Check if the variable already exists in the bundle + const variableExists = variableBundle.variables.some( + v => v.unitName === variable.unitName && v.variableId === variable.variableId + ); + + if (!variableExists) { + variableBundle.variables.push(variable); + return this.variableBundleRepository.save(variableBundle); + } + + return variableBundle; + } + + /** + * Remove a variable from a variable bundle + * @param id The ID of the variable bundle + * @param workspaceId The ID of the workspace + * @param unitName The unit name of the variable + * @param variableId The variable ID + * @returns The updated variable bundle + * @throws NotFoundException if the variable bundle is not found + */ + async removeVariableFromBundle( + id: number, + workspaceId: number, + unitName: string, + variableId: string + ): Promise { + const variableBundle = await this.getVariableBundle(id, workspaceId); + + // Filter out the variable to remove + variableBundle.variables = variableBundle.variables.filter( + v => !(v.unitName === unitName && v.variableId === variableId) + ); + + return this.variableBundleRepository.save(variableBundle); + } +} diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 53f02ca21..8c512c3aa 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Connection, In, Repository } from 'typeorm'; +import { Connection, Repository } from 'typeorm'; import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; import { Booklet } from '../entities/booklet.entity'; @@ -61,34 +61,7 @@ export class WorkspaceTestResultsService { throw new Error('Both personId and workspaceId are required.'); } - // Generate a cache key for booklet data - const cacheKey = `booklets:${workspaceId}:${personId}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - id: number; - personid: number; - name: string; - size: number; - logs: { id: number; bookletid: number; ts: string; parameter: string, key: string }[]; - sessions: { id: number; browser: string; os: string; screen: string; ts: string }[]; - units: { - id: number; - bookletid: number; - name: string; - alias: string | null; - results: { id: number; unitid: number }[]; - logs: { id: number; unitid: number; ts: string; key: string; parameter: string }[]; - tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; - }[]; - }[]>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached booklet data for person ${personId} in workspace ${workspaceId}`); - return cachedResult; - } - - this.logger.log(`Cache miss for booklet data for person ${personId} in workspace ${workspaceId}`); + this.logger.log(`Fetching booklet data for person ${personId} in workspace ${workspaceId}`); try { this.logger.log( @@ -252,11 +225,6 @@ export class WorkspaceTestResultsService { })) })); - // Store the result in Redis cache (24 hours TTL for booklet data) - const ONE_DAY_SECONDS = 24 * 60 * 60; - await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); - this.logger.log(`Cached booklet data for person ${personId} in workspace ${workspaceId}`); - return result; } catch (error) { this.logger.error( @@ -278,17 +246,7 @@ export class WorkspaceTestResultsService { const validPage = Math.max(1, page); // minimum 1 const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT - // Generate a cache key based on the parameters - const cacheKey = `test-results:${workspace_id}:${validPage}:${validLimit}:${searchText || ''}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[Persons[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); - return cachedResult; - } - - this.logger.log(`Cache miss for test results in workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); + this.logger.log(`Fetching test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); try { const queryBuilder = this.personsRepository.createQueryBuilder('person') @@ -316,11 +274,7 @@ export class WorkspaceTestResultsService { .orderBy('person.code', 'ASC'); const [results, total] = await queryBuilder.getManyAndCount(); - - // Store the result in Redis cache (30 seconds TTL for test results) const result: [Persons[], number] = [results, total]; - await this.cacheService.set(cacheKey, result, 30); - this.logger.log(`Cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); return result; } catch (error) { @@ -332,18 +286,6 @@ export class WorkspaceTestResultsService { async findWorkspaceResponses(workspace_id: number, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log('Returning responses for workspace', workspace_id); - // Generate a cache key based on the parameters - const cacheKey = `workspace-responses:${workspace_id}:${options?.page || 0}:${options?.limit || 0}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached workspace responses for workspace ${workspace_id}`); - return cachedResult; - } - - this.logger.log(`Cache miss for workspace responses in workspace ${workspace_id}`); - let result: [ResponseEntity[], number]; if (options) { @@ -369,10 +311,6 @@ export class WorkspaceTestResultsService { result = [responses, responses.length]; } - // Store the result in Redis cache (45 seconds TTL for workspace responses) - await this.cacheService.set(cacheKey, result, 45); - this.logger.log(`Cached workspace responses for workspace ${workspace_id}`); - return result; } @@ -389,43 +327,54 @@ export class WorkspaceTestResultsService { // If not in cache, fetch from database const [login, code, bookletId] = connector.split('@'); - const person = await this.personsRepository.findOne({ - where: { - code, login, workspace_id: workspaceId, consider: true + const queryBuilder = this.unitRepository.createQueryBuilder('unit') + .innerJoinAndSelect('unit.responses', 'response') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .innerJoin('booklet.bookletinfo', 'bookletinfo') + .where('person.login = :login', { login }) + .andWhere('person.code = :code', { code }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }) + .andWhere('person.consider = :consider', { consider: true }) + .andWhere('bookletinfo.name = :bookletId', { bookletId }) + .andWhere('unit.alias = :unitId', { unitId }); + + const unit = await queryBuilder.getOne(); + + if (!unit) { + // If no unit found, we need to determine which part of the query failed + const person = await this.personsRepository.findOne({ + where: { + code, login, workspace_id: workspaceId, consider: true + } + }); + + if (!person) { + throw new Error(`Person mit Login ${login} und Code ${code} wurde nicht gefunden.`); } - }); - if (!person) { - throw new Error(`Person mit ID ${person.id} wurde nicht gefunden.`); - } - const bookletInfo = await this.bookletInfoRepository.findOne({ - where: { name: bookletId } - }); + const bookletInfo = await this.bookletInfoRepository.findOne({ + where: { name: bookletId } + }); - if (!bookletInfo) { - throw new Error(`Kein Booklet mit der ID ${bookletId} gefunden.`); - } + if (!bookletInfo) { + throw new Error(`Kein Booklet mit der ID ${bookletId} gefunden.`); + } - const booklet = await this.bookletRepository.findOne({ - where: { - personid: person.id, - infoid: bookletInfo.id + const booklet = await this.bookletRepository.findOne({ + where: { + personid: person.id, + infoid: bookletInfo.id + } + }); + + if (!booklet) { + throw new Error(`Kein Booklet für die Person mit ID ${person.id} und Booklet ID ${bookletId} gefunden.`); } - }); - if (!booklet) { - throw new Error(`Kein Booklet für die Person mit ID ${person.id} und Booklet ID ${bookletId} gefunden.`); + throw new Error(`Keine Unit mit der ID ${unitId} für das Booklet ${bookletId} gefunden.`); } - const booklets = [booklet]; - const unit = await this.unitRepository.findOne({ - where: { - bookletid: In(booklets.map(b => b.id)), - alias: unitId - }, - relations: ['responses'] - }); - const responsesBySubform = {}; unit.responses.forEach(response => { @@ -486,22 +435,9 @@ export class WorkspaceTestResultsService { return result; } - private readonly RESPONSES_CACHE_TTL_SECONDS = 60; // 1 minute cache TTL - async getResponsesByStatus(workspace_id: number, status: string, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log(`Getting responses with status ${status} for workspace ${workspace_id}`); - const cacheKey = `responses:status:${workspace_id}:${status}:${options?.page || 0}:${options?.limit || 0}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached responses for status ${status} (workspace ${workspace_id})`); - return cachedResult; - } - - this.logger.log(`Cache miss for responses with status ${status} (workspace ${workspace_id})`); - try { const queryBuilder = this.responseRepository.createQueryBuilder('response') .leftJoinAndSelect('response.unit', 'unit') @@ -538,10 +474,6 @@ export class WorkspaceTestResultsService { this.logger.log(`Found ${result[0].length} responses with status ${status} for workspace ${workspace_id}`); } - // Store the result in Redis cache - await this.cacheService.set(cacheKey, result, this.RESPONSES_CACHE_TTL_SECONDS); - this.logger.log(`Cached responses with status ${status} for workspace ${workspace_id}`); - return result; } catch (error) { this.logger.error(`Error getting responses by status: ${error.message}`); @@ -865,38 +797,7 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; - // Generate a cache key based on the search parameters and pagination - const cacheKey = `search-responses:${workspaceId}:${JSON.stringify(searchParams)}:${page}:${limit}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - data: { - responseId: number; - variableId: string; - value: string; - status: string; - code?: number; - score?: number; - codedStatus?: string; - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - }[]; - total: number; - }>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached search results for workspace ${workspaceId}`); - return cachedResult; - } - - this.logger.log(`Cache miss for search results in workspace ${workspaceId}`); + this.logger.log(`Searching for responses in workspace ${workspaceId}`); try { this.logger.log( @@ -972,10 +873,6 @@ export class WorkspaceTestResultsService { const result = { data, total }; - const ONE_DAY_SECONDS = 24 * 60 * 60; - await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); - this.logger.log(`Cached search results for workspace ${workspaceId}`); - return result; } catch (error) { this.logger.error( @@ -1014,33 +911,7 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; - // Generate a cache key based on the parameters - const cacheKey = `units-by-name:${workspaceId}:${unitName}:${page}:${limit}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - data: { - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; - responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; - }[]; - total: number; - }>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); - return cachedResult; - } - - this.logger.log(`Cache miss for units by name in workspace ${workspaceId}, unitName: ${unitName}`); + this.logger.log(`Finding units by name for workspace ${workspaceId}, unitName: ${unitName}`); try { this.logger.log( @@ -1111,10 +982,6 @@ export class WorkspaceTestResultsService { data = Array.from(uniqueMap.values()); const result = { data, total: data.length }; - // Store the result in Redis cache (60 seconds TTL for units by name) - await this.cacheService.set(cacheKey, result, 60); - this.logger.log(`Cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); - return result; } catch (error) { this.logger.error( diff --git a/apps/backend/src/app/database/services/workspace-users.service.ts b/apps/backend/src/app/database/services/workspace-users.service.ts index f658d0625..5a3d471c3 100644 --- a/apps/backend/src/app/database/services/workspace-users.service.ts +++ b/apps/backend/src/app/database/services/workspace-users.service.ts @@ -99,7 +99,7 @@ export class WorkspaceUsersService { this.logger.log(`Retrieving coders (users with accessLevel 1) for workspace ID: ${workspaceId}`); try { - const users = await this.workspaceUsersRepository.find({ + const workspaceUsers = await this.workspaceUsersRepository.find({ where: { workspaceId, accessLevel: 1 @@ -107,8 +107,32 @@ export class WorkspaceUsersService { order: { userId: 'ASC' } }); - this.logger.log(`Found ${users.length} coder(s) for workspace ID: ${workspaceId}`); - return [users, users.length]; + if (workspaceUsers.length === 0) { + this.logger.log(`No coders found for workspace ID: ${workspaceId}`); + return [workspaceUsers, 0]; + } + + const userIds = workspaceUsers.map(wu => wu.userId); + + const users = await this.usersRepository.find({ + where: { id: In(userIds) } + }); + + const userMap = new Map(); + users.forEach(user => { + userMap.set(user.id, user.username); + }); + + const enhancedUsers = workspaceUsers.map(wu => { + const username = userMap.get(wu.userId); + return { + ...wu, + username + }; + }); + + this.logger.log(`Found ${enhancedUsers.length} coder(s) for workspace ID: ${workspaceId}`); + return [enhancedUsers as WorkspaceUser[], enhancedUsers.length]; } catch (error) { this.logger.error(`Failed to retrieve coders for workspace ID: ${workspaceId}`, error.stack); throw new Error('Could not retrieve workspace coders'); diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts index bd4f51578..1ffcf2e95 100644 --- a/apps/frontend/src/app/coding/coding.routes.ts +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -11,5 +11,10 @@ export const codingRoutes: Routes = [ path: 'test-person-coding/:workspace_id', canActivate: [canActivateAuth], loadComponent: () => import('./components/test-person-coding/test-person-coding.component').then(m => m.TestPersonCodingComponent) + }, + { + path: 'coding', + canActivate: [canActivateAuth], + loadComponent: () => import('./components/my-coding-jobs/my-coding-jobs.component').then(m => m.MyCodingJobsComponent) } ]; 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 new file mode 100644 index 000000000..33272f509 --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html @@ -0,0 +1,322 @@ +
+

{{ data.isEdit ? 'Kodierjob bearbeiten' : 'Neuen Kodierjob erstellen' }}

+ + +
+
+
+

Allgemeine Informationen

+ + + Name + + @if (codingJobForm.get('name')?.invalid && codingJobForm.get('name')?.touched) { + Name ist erforderlich + } + + + + Beschreibung + + + + + Status + + Ausstehend + Aktiv + Abgeschlossen + + +
+ +
+

Variablenbündel

+

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

+ + + + + @if (isLoadingCoders) { +
+ +

Lade Kodierer...

+
+ } + + @if (!isLoadingCoders && coders.length > 0) { +
+ + + + + + + + + + @for (coder of coders; track 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.

+
+ } +
+ + + + +
+
+ + Aufgaben-ID Filter + + + + + + Variablen-ID Filter + + + + + + + +
+
+ + @if (isLoadingVariableAnalysis) { +
+ +

Lade Variablen...

+
+ } + + @if (!isLoadingVariableAnalysis && variableBundles.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}
+ + + +
+ +
+

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

+
+ } + + @if (!isLoadingVariableAnalysis && variableBundles.length === 0) { +
+ analytics +

Keine Variablen verfügbar

+

Es wurden keine Variablen gefunden. Bitte überprüfen Sie Ihre Filter oder versuchen Sie es später erneut.

+
+ } +
+ + +
+
+ + Name Filter + + + + + + + +
+
+ + @if (isLoadingBundles) { +
+ +

Lade Variablenbündel...

+
+ } + + @if (!isLoadingBundles && variableBundles.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name{{ element.name }}Beschreibung{{ element.description }}Anzahl Variablen{{ getVariableCount(element) }}
+
+ +
+

{{ selectedVariableBundles.selected.length }} Variablenbündel ausgewählt

+
+ + @if (selectedVariableBundles.selected.length > 0) { +
+

Vorschau der ausgewählten Variablenbündel

+ + @for (bundle of selectedVariableBundles.selected; track bundle.id) { + + + + {{ bundle.name }} + + + {{ getVariableCount(bundle) }} Variablen + + + + + + + + + + + + @for (variable of bundle.variables; track variable.unitName + variable.variableId) { + + + + + } + +
Aufgaben-IDVariablen-ID
{{ variable.unitName }}{{ variable.variableId }}
+
+ } +
+
+ } + } + + @if (!isLoadingBundles && variableBundles.length === 0) { +
+ analytics +

Keine Variablenbündel verfügbar

+

Es wurden keine Variablenbündel gefunden. Bitte erstellen Sie zuerst Variablenbündel über die Variablenbündel-Verwaltung.

+
+ } +
+
+
+
+
+ +
+ + +
+
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 new file mode 100644 index 000000000..edebfeb87 --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss @@ -0,0 +1,178 @@ +.dialog-container { + display: flex; + flex-direction: column; + max-height: 90vh; + min-width: 800px; + padding: 0; +} + +.dialog-title { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + padding: 16px 24px; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + padding: 16px 24px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 8px 24px 16px; + gap: 8px; +} + +.form-section { + margin-bottom: 24px; + + h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 16px; + } + + h4 { + font-size: 1.1rem; + font-weight: 500; + margin: 16px 0; + } + + .section-description { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 16px; + } +} + +.full-width { + width: 100%; +} + +.filter-container { + margin: 16px 0; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.filter-field { + flex: 1; + min-width: 200px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +.table-container { + max-height: 300px; + overflow: auto; + margin-bottom: 16px; +} + +.variable-bundles-table { + width: 100%; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected-row { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-select { + width: 60px; + padding-left: 16px; + } +} + +.selection-summary { + margin-top: 16px; + font-weight: 500; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + text-align: center; + + .empty-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: rgba(0, 0, 0, 0.3); + margin-bottom: 16px; + } + + h3 { + margin-bottom: 8px; + } + + .empty-text { + color: rgba(0, 0, 0, 0.6); + max-width: 400px; + } +} + +// Styles for the tabbed interface +::ng-deep .mat-mdc-tab-body-content { + padding: 16px 0; +} + +// Styles for the bundle preview +.bundle-preview-container { + margin-top: 24px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.02); +} + +.preview-table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; + + th, td { + padding: 8px; + text-align: left; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + th { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } + + tr:last-child td { + border-bottom: none; + } + + tr:hover { + background-color: rgba(0, 0, 0, 0.04); + } +} 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 new file mode 100644 index 000000000..1376ca11c --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts @@ -0,0 +1,336 @@ +import { + Component, Inject, OnInit, inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators +} from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +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 { MatSortModule } from '@angular/material/sort'; +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 { TranslateModule } from '@ngx-translate/core'; +import { SelectionModel } from '@angular/cdk/collections'; +import { CodingJob, VariableBundle, Variable } from '../../models/coding-job.model'; +import { Coder } from '../../models/coder.model'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { CoderService } from '../../services/coder.service'; +import { VariableAnalysisItem } from '../../models/variable-analysis-item.model'; + +export interface CodingJobDialogData { + codingJob?: CodingJob; + isEdit: boolean; +} + +@Component({ + selector: 'coding-box-coding-job-dialog', + templateUrl: './coding-job-dialog.component.html', + styleUrls: ['./coding-job-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + MatChipsModule, + MatTableModule, + MatCheckboxModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + MatTabsModule, + MatExpansionModule, + TranslateModule + ] +}) +export class CodingJobDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private backendService = inject(BackendService); + private appService = inject(AppService); + private coderService = inject(CoderService); + + codingJobForm!: FormGroup; + isLoading = false; + + // Variables + variables: Variable[] = []; + selectedVariables = new SelectionModel(true, []); + displayedColumns: string[] = ['select', 'unitName', 'variableId']; + dataSource = new MatTableDataSource([]); + + // Coders + coders: Coder[] = []; + isLoadingCoders = false; + + // Variable bundles + variableBundles: VariableBundle[] = []; + selectedVariableBundles = new SelectionModel(true, []); + bundlesDisplayedColumns: string[] = ['select', 'name', 'description', 'variableCount']; + bundlesDataSource = new MatTableDataSource([]); + isLoadingBundles = false; + + // Variable analysis items + variableAnalysisItems: VariableAnalysisItem[] = []; + isLoadingVariableAnalysis = false; + totalVariableAnalysisRecords = 0; + variableAnalysisPageIndex = 0; + variableAnalysisPageSize = 10; + variableAnalysisPageSizeOptions = [5, 10, 25, 50]; + + // Filters + unitNameFilter = ''; + variableIdFilter = ''; + bundleNameFilter = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: CodingJobDialogData + ) {} + + ngOnInit(): void { + this.initForm(); + this.loadVariableAnalysisItems(); + this.loadVariableBundles(); + + // 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 coders assigned to the current job + * @param jobId The ID of the job + */ + loadCoders(jobId: number): void { + this.isLoadingCoders = true; + + this.coderService.getCodersByJobId(jobId).subscribe({ + next: coders => { + this.coders = coders; + this.isLoadingCoders = false; + }, + error: () => { + this.isLoadingCoders = false; + } + }); + } + + initForm(): void { + this.codingJobForm = this.fb.group({ + name: [this.data.codingJob?.name || '', Validators.required], + description: [this.data.codingJob?.description || ''], + status: [this.data.codingJob?.status || 'pending', Validators.required] + }); + + if (this.data.codingJob?.variables) { + this.variables = [...this.data.codingJob.variables]; + this.dataSource.data = this.variables; + this.selectedVariables = new SelectionModel(true, [...this.variables]); + } + + if (this.data.codingJob?.variableBundles) { + this.selectedVariableBundles = new SelectionModel(true, [...this.data.codingJob.variableBundles]); + } + } + + loadVariableAnalysisItems(page: number = 1, limit: number = 10): void { + this.isLoadingVariableAnalysis = true; + const workspaceId = this.appService.selectedWorkspaceId; + + if (!workspaceId) { + this.isLoadingVariableAnalysis = false; + return; + } + + this.backendService.getVariableAnalysis( + workspaceId, + page, + limit, + this.unitNameFilter || undefined, + this.variableIdFilter || 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()); + 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( + b => b.unitName === variable.unitName && b.variableId === variable.variableId + ); + if (foundVariable) { + this.selectedVariables.select(foundVariable); + } + }); + } + + this.totalVariableAnalysisRecords = response.total; + this.variableAnalysisPageIndex = page - 1; + this.isLoadingVariableAnalysis = false; + }, + error: () => { + this.isLoadingVariableAnalysis = false; + } + }); + } + + loadVariableBundles(): void { + this.isLoadingBundles = true; + + // Get the current workspace ID from the app service + const workspaceId = this.appService.selectedWorkspaceId; + + if (workspaceId) { + this.backendService.getVariableBundles(workspaceId).subscribe({ + next: bundles => { + this.variableBundles = bundles; + this.bundlesDataSource.data = bundles; + this.isLoadingBundles = false; + }, + error: () => { + this.isLoadingBundles = false; + } + }); + } else { + this.isLoadingBundles = false; + } + } + + onPageChange(event: PageEvent): void { + this.loadVariableAnalysisItems(event.pageIndex + 1, event.pageSize); + } + + applyFilter(): void { + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + applyBundleFilter(): void { + if (this.bundleNameFilter) { + this.bundlesDataSource.filter = this.bundleNameFilter.trim().toLowerCase(); + } else { + this.bundlesDataSource.filter = ''; + } + } + + clearFilters(): void { + this.unitNameFilter = ''; + this.variableIdFilter = ''; + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + clearBundleFilter(): void { + this.bundleNameFilter = ''; + this.bundlesDataSource.filter = ''; + } + + /** Whether the number of selected bundle matches the total number of rows. */ + isAllBundlesSelected(): boolean { + const numSelected = this.selectedVariableBundles.selected.length; + const numRows = this.bundlesDataSource.data.length; + 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 { + return bundle.variables.length; + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected(): boolean { + const numSelected = this.selectedVariableBundles.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + masterToggle(): void { + if (this.isAllSelected()) { + this.selectedVariableBundles.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; + } + + const codingJob: CodingJob = { + id: this.data.codingJob?.id || 0, + ...this.codingJobForm.value, + createdAt: this.data.codingJob?.createdAt || new Date(), + updatedAt: new Date(), + assignedCoders: this.data.codingJob?.assignedCoders || [], + variables: this.selectedVariables.selected, + variableBundles: this.selectedVariableBundles.selected + }; + + this.dialogRef.close(codingJob); + } + + onCancel(): void { + this.dialogRef.close(); + } +} 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 ce925db26..305c5e327 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 @@ -16,6 +16,10 @@ play_arrow Kodierjob starten + + person_add + Kodierer zuweisen + @if (isLoading) { @@ -68,17 +72,24 @@ - + + Zugewiesene Kodierer + + {{getAssignedCoderNames(element)}} + + + + Erstellt am - {{element.created_at | date: 'dd.MM.yyyy HH:mm'}} + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} - + Aktualisiert am - {{element.updated_at | date: 'dd.MM.yyyy HH:mm'}} + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} 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 1183a51de..8c56cac58 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 @@ -80,6 +80,23 @@ mat-cell { flex: 0 0 120px; } +.mat-column-assignedCoders { + flex: 0 0 180px; +} + +.assigned-coders-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: #1976d2; + } +} + .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 563c33879..e71828009 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 + Component, OnInit, ViewChild, AfterViewInit, inject, Input } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { MatSort, MatSortModule } from '@angular/material/sort'; @@ -13,16 +13,21 @@ import { MatTableDataSource } from '@angular/material/table'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { SelectionModel } from '@angular/cdk/collections'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; 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 { 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 { CodingJobDialogComponent } from '../coding-job-dialog/coding-job-dialog.component'; +import { Coder } from '../../models/coder.model'; +import { CoderService } from '../../services/coder.service'; @Component({ selector: 'coding-box-coding-jobs', @@ -49,15 +54,24 @@ import { CodingJob } from '../../models/coding-job.model'; MatRowDef, MatColumnDef, MatSortModule, - MatButton + MatButton, + MatDialogModule, + MatTooltipModule ] }) export class CodingJobsComponent implements OnInit, AfterViewInit { appService = inject(AppService); backendService = inject(BackendService); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + private coderService = inject(CoderService); - displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'createdAt', 'updatedAt']; + // Cache for storing coder names by job ID + private coderNamesByJobId = new Map(); + + @Input() selectedCoder: Coder | null = null; + + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'assignedCoders', 'createdAt', 'updatedAt']; dataSource = new MatTableDataSource([]); selection = new SelectionModel(true, []); isLoading = false; @@ -139,16 +153,66 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } createCodingJob(): void { - this.snackBar.open('Funktion zum Erstellen eines Kodierjobs noch nicht implementiert', 'Schließen', { duration: 3000 }); + const dialogRef = this.dialog.open(CodingJobDialogComponent, { + width: '900px', + data: { + isEdit: false + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const newId = this.getNextId(); + const newCodingJob: CodingJob = { + ...result, + id: newId + }; + const currentData = this.dataSource.data; + this.dataSource.data = [...currentData, newCodingJob]; + this.snackBar.open(`Kodierjob "${newCodingJob.name}" wurde erstellt`, 'Schließen', { duration: 3000 }); + } + }); } editCodingJob(): void { if (this.selection.selected.length === 1) { const selectedJob = this.selection.selected[0]; - this.snackBar.open(`Bearbeiten von Kodierjob "${selectedJob.name}" noch nicht implementiert`, 'Schließen', { duration: 3000 }); + + const dialogRef = this.dialog.open(CodingJobDialogComponent, { + width: '900px', + data: { + codingJob: selectedJob, + isEdit: true + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const currentData = this.dataSource.data; + const index = currentData.findIndex(job => job.id === result.id); + + if (index !== -1) { + const updatedData = [...currentData]; + updatedData[index] = result; + this.dataSource.data = updatedData; + + this.snackBar.open(`Kodierjob "${result.name}" wurde aktualisiert`, 'Schließen', { duration: 3000 }); + } + } + }); } } + /** + * Gets the next available ID for a new coding job + */ + private getNextId(): number { + const jobs = this.dataSource.data; + return jobs.length > 0 ? + Math.max(...jobs.map(job => job.id)) + 1 : + 1; + } + deleteCodingJobs(): void { if (this.selection.selected.length > 0) { const count = this.selection.selected.length; @@ -188,4 +252,132 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { return status; } } + + /** + * 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 + */ + getAssignedCoderNames(job: CodingJob): string { + if (!job.assignedCoders || job.assignedCoders.length === 0) { + return 'Keine'; + } + + // 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 => { + 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`); + } + }); + + // Return a loading indicator while we fetch the names + return 'Lade Kodierer...'; + } + + // 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`; + } + + return coderNames; + } + + /** + * 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 we haven't fetched the names yet, show a loading message + if (!this.coderNamesByJobId.has(job.id)) { + return 'Lade Kodierer...'; + } + + // Return the full list of coder names + return this.coderNamesByJobId.get(job.id) || `${job.assignedCoders.length} Kodierer`; + } } 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 49df0c89f..3e8173887 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 @@ -18,6 +18,16 @@

Manuelle Kodierung planen

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


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

Kodierer

@@ -25,7 +35,11 @@

Kodierer

Kodierjobs

- + +
+
+

Variablenbündel

+
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 80c25f552..3903deda1 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 @@ -93,13 +93,43 @@ border-top: 1px solid rgba(0, 0, 0, 0.1); } + // Coder selection styles + .coder-selection-container { + margin-bottom: 20px; + + .coder-select { + width: 100%; + max-width: 400px; + } + + ::ng-deep .mat-mdc-form-field { + width: 100%; + max-width: 400px; + + .mat-mdc-select-value { + font-size: 14px; + } + + .mat-mdc-form-field-infix { + width: auto; + min-width: 200px; + } + } + } + .statistics-content { margin: 20px 0; - .coder-list-container, .coding-jobs-container { + .coder-list-container, .coding-jobs-container, .variable-bundle-container { margin-bottom: 20px; } + .variable-bundle-container { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + h3 { font-size: 18px; font-weight: 500; 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 558f24256..c1f263029 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,18 +1,58 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; +import { NgFor } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { MatAnchor, MatButton } from '@angular/material/button'; -import { - MatCard, MatCardContent, MatCardHeader, MatCardTitle -} from '@angular/material/card'; 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: [TranslateModule, CoderListComponent, MatAnchor, CodingJobsComponent, MatCardContent, MatCardTitle, MatCardHeader, MatCard, MatIcon, MatButton] + imports: [ + NgFor, + TranslateModule, + CoderListComponent, + MatAnchor, + CodingJobsComponent, + MatIcon, + MatButton, + MatFormField, + MatLabel, + MatSelect, + MatOption, + VariableBundleManagerComponent + ] }) -export class CodingManagementManualComponent { +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; + } } diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html new file mode 100644 index 000000000..39eb9e49c --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html @@ -0,0 +1,91 @@ +
+
+

Meine Kodierjobs

+

Hier finden Sie alle Kodierjobs, die Ihnen zugewiesen wurden

+ + @if (isLoading) { +
+ +

Kodierjobs werden geladen...

+
+ } + + @if (!isAuthorized && !isLoading) { +
+ lock +

Zugriff verweigert

+

Sie haben keinen Zugriff auf diese Seite. Nur Kodierer (Benutzer mit Zugriffsebene 1) können auf diese Seite zugreifen.

+ Zurück zur Startseite +
+ } + + @if (!isLoading && isAuthorized) { +
+ + +
+ + + + + + + Name + + {{element.name}} + + + + + Beschreibung + + {{element.description}} + + + + + Status + + {{getStatusText(element.status)}} + + + + + Erstellt am + + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktualisiert am + + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + @if (dataSource.data.length === 0) { +
+ assignment +

Keine Kodierjobs für Sie vorhanden.

+
+ } +
+ +
+ } +
+
diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss new file mode 100644 index 000000000..7baa88816 --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss @@ -0,0 +1,233 @@ +.page-body { + height: calc(90% - 5px); +} + +.admin-background { + box-shadow: 5px 10px 20px black; + background-color: white; + padding: 25px; + margin: 0 15px 15px 15px; + height: calc(100% - 65px); +} + +.page-title { + font-size: 28px; + font-weight: 500; + margin: 0 0 8px 0; + color: #333; +} + +.subtitle { + font-size: 16px; + color: #666; + margin: 0 0 20px 0; +} + +.flex-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.filter-section { + margin-bottom: 16px; +} + +.filter-action-container { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.table-container { + flex: 1; + overflow: auto; + position: relative; +} + +.coding-jobs-table { + width: 100%; + min-width: 800px; /* Ensures table doesn't get too narrow */ +} + +.cell-content { + padding: 8px 0; + display: block; +} + +.date-cell { + color: #666; + font-size: 14px; +} + +.action-section { + display: flex; + justify-content: flex-end; + padding: 16px 0 0; + margin-top: 10px; +} + +.action-button { + padding: 0 24px; + height: 48px; + font-size: 16px; +} + +.no-data-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 32px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: #9e9e9e; + } + + p { + font-size: 18px; + color: #666; + margin: 0; + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 24px; + margin-top: 20px; + + p { + font-size: 18px; + color: #666; + } +} + +.unauthorized-message { + margin: 40px auto; + padding: 20px; + text-align: center; + max-width: 600px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: #f44336; + } + + h3 { + font-size: 24px; + margin: 0; + color: #333; + } + + p { + font-size: 16px; + color: #666; + margin: 0 0 16px 0; + max-width: 450px; + line-height: 1.5; + } + + a { + margin-top: 8px; + } +} + +/* Status badges */ +.status-badge { + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 500; + display: inline-block; +} + +.status-active { + background-color: rgba(76, 175, 80, 0.1); + color: #2e7d32; + border: 1px solid rgba(76, 175, 80, 0.2); +} + +.status-completed { + background-color: rgba(33, 150, 243, 0.1); + color: #1565c0; + border: 1px solid rgba(33, 150, 243, 0.2); +} + +.status-pending { + background-color: rgba(255, 152, 0, 0.1); + color: #ef6c00; + border: 1px solid rgba(255, 152, 0, 0.2); +} + +/* Table styling */ +mat-header-row { + background-color: #f8f9fa; + border-bottom: 2px solid #e0e0e0; + min-height: 56px; +} + +mat-row { + min-height: 52px; + transition: background-color 0.2s ease; +} + +mat-row:hover { + background-color: rgba(0, 0, 0, 0.02); + cursor: pointer; +} + +mat-row.selected { + background-color: rgba(33, 150, 243, 0.05); +} + +mat-header-cell { + color: #424242; + font-size: 14px; + font-weight: 500; +} + +mat-cell { + font-size: 15px; + color: #333; +} + +/* Responsive adjustments */ +@media (max-width: 1200px) { + .content-wrapper { + padding: 16px 24px; + } +} + +@media (max-width: 768px) { + .header-section { + padding: 16px 24px; + } + + .content-wrapper { + padding: 16px; + } + + .page-title { + font-size: 24px; + } +} diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts new file mode 100644 index 000000000..9f857539a --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts @@ -0,0 +1,195 @@ +import { + Component, OnInit, ViewChild, AfterViewInit, inject +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { + MatCell, MatCellDef, MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, + MatRow, MatRowDef, + MatTable, + MatTableDataSource +} from '@angular/material/table'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SelectionModel } from '@angular/cdk/collections'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatAnchor, MatButton } from '@angular/material/button'; +import { DatePipe, NgClass } from '@angular/common'; +import { Router } from '@angular/router'; +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 { WorkspaceUserDto } from '../../../../../../../api-dto/workspaces/workspace-user-dto'; +import { CoderService } from '../../services/coder.service'; + +@Component({ + selector: 'coding-box-my-coding-jobs', + templateUrl: './my-coding-jobs.component.html', + styleUrls: ['./my-coding-jobs.component.scss'], + standalone: true, + imports: [ + TranslateModule, + DatePipe, + NgClass, + SearchFilterComponent, + MatIcon, + MatHeaderCell, + MatCell, + MatHeaderRow, + MatRow, + MatProgressSpinner, + MatTable, + MatAnchor, + MatHeaderCellDef, + MatCellDef, + MatHeaderRowDef, + MatRowDef, + MatColumnDef, + MatSortModule, + MatButton + ] +}) +export class MyCodingJobsComponent implements OnInit, AfterViewInit { + appService = inject(AppService); + backendService = inject(BackendService); + private snackBar = inject(MatSnackBar); + private router = inject(Router); + private coderService = inject(CoderService); + + displayedColumns: string[] = ['name', 'description', 'status', 'createdAt', 'updatedAt']; + dataSource = new MatTableDataSource([]); + selection = new SelectionModel(true, []); + isLoading = false; + currentUserId = 0; + isAuthorized = false; + + @ViewChild(MatSort) sort!: MatSort; + + ngOnInit(): void { + this.appService.authData$.subscribe(authData => { + this.currentUserId = authData.userId; + if (authData.workspaces && authData.workspaces.length > 0) { + this.checkUserAccessLevel(); + } else { + this.router.navigate(['/']); + this.snackBar.open('Sie haben keinen Zugriff auf diese Seite', 'Schließen', { duration: 3000 }); + } + }); + } + + private checkUserAccessLevel(): void { + this.backendService.getWorkspaceUsers(1).subscribe(users => { + const currentUser = users.data.find((user: WorkspaceUserDto) => user.userId === this.currentUserId); + if (currentUser && currentUser.accessLevel === 1) { + this.isAuthorized = true; + this.loadMyCodingJobs(); + } else { + this.router.navigate(['/']); + this.snackBar.open( + 'Sie haben keinen Zugriff auf diese Seite. Nur Kodierer können auf diese Seite zugreifen.', + 'Schließen', + { duration: 3000 } + ); + } + }); + } + + ngAfterViewInit(): void { + this.dataSource.sort = this.sort; + } + + loadMyCodingJobs(): void { + this.isLoading = true; + + const sampleJobs = [ + { + 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: [1] + } + ]; + + this.coderService.getCodersByJobId(this.currentUserId).subscribe({ + next: coders => { + if (coders.length > 0) { + const currentCoder = coders[0]; + const assignedJobIds = currentCoder.assignedJobs || []; + + this.dataSource.data = sampleJobs.filter(job => assignedJobIds.includes(job.id)); + } else { + this.dataSource.data = []; + } + + this.isLoading = false; + }, + error: () => { + this.snackBar.open('Fehler beim Laden der Kodierjobs', 'Schließen', { duration: 3000 }); + this.isLoading = false; + } + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + selectRow(row: CodingJob): void { + this.selection.toggle(row); + } + + startCodingJob(job: CodingJob): void { + this.snackBar.open(`Starten von Kodierjob "${job.name}" noch nicht implementiert`, 'Schließen', { duration: 3000 }); + } + + getStatusClass(status: string): string { + switch (status) { + case 'active': + return 'status-active'; + case 'completed': + return 'status-completed'; + case 'pending': + return 'status-pending'; + default: + return ''; + } + } + + getStatusText(status: string): string { + switch (status) { + case 'active': + return 'Aktiv'; + case 'completed': + return 'Abgeschlossen'; + case 'pending': + return 'Ausstehend'; + default: + return status; + } + } +} 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 new file mode 100644 index 000000000..1be69c0c2 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html @@ -0,0 +1,183 @@ +
+

{{ data.isEdit ? 'Variablenbündel bearbeiten' : 'Neues Variablenbündel erstellen' }}

+ + +
+
+
+

Allgemeine Informationen

+ + + Name + + @if (bundleGroupForm.get('name')?.invalid && bundleGroupForm.get('name')?.touched) { + Name ist erforderlich + } + + + + Beschreibung + + +
+ +
+

Variablen im Bündel

+

Aktuell ausgewählte Variablen in diesem Bündel:

+ +
+ @if (selectedVariablesDataSource.data.length === 0) { +
+

Keine Variablen ausgewählt. Wählen Sie unten Variablen aus, um sie diesem Bündel hinzuzufügen.

+
+ } + @if (selectedVariablesDataSource.data.length > 0) { + + + + + + + + + + + + + + + + + + + + + +
Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}Aktionen + +
+ } +
+
+ +
+

Verfügbare Variablen

+

Wählen Sie Variablen aus, um sie dem Bündel hinzuzufügen:

+ + +
+
+ + Aufgaben-ID Filter + + + + + + Variablen-ID Filter + + + + + + + +
+
+ + @if (isLoadingVariableAnalysis) { +
+ +

Lade Variablen...

+
+ } + + @if (!isLoadingVariableAnalysis && availableVariables.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}
+ + + +
+ +
+ +
+ } + + @if (!isLoadingVariableAnalysis && availableVariables.length === 0) { +
+ analytics +

Keine Variablen verfügbar

+

Es wurden keine Variablen gefunden. Bitte überprüfen Sie Ihre Filter oder versuchen Sie es später erneut.

+
+ } +
+
+
+ +
+ + +
+
diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss new file mode 100644 index 000000000..f4a457e41 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss @@ -0,0 +1,147 @@ +.dialog-container { + display: flex; + flex-direction: column; + max-height: 90vh; + min-width: 800px; + padding: 0; +} + +.dialog-title { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + padding: 16px 24px; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + padding: 16px 24px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 8px 24px 16px; + gap: 8px; +} + +.form-section { + margin-bottom: 24px; + + h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 16px; + } + + .section-description { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 16px; + } +} + +.full-width { + width: 100%; +} + +.filter-container { + margin-bottom: 16px; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.filter-field { + flex: 1; + min-width: 200px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +.table-container { + max-height: 300px; + overflow: auto; + margin-bottom: 16px; +} + +.variable-bundles-table { + width: 100%; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected-row { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-select { + width: 60px; + padding-left: 16px; + } +} + +.selected-variables-container { + margin-bottom: 24px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.02); +} + +.selected-variables-table { + width: 100%; +} + +.action-buttons { + display: flex; + justify-content: flex-start; + margin-top: 16px; + gap: 8px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + text-align: center; + + .empty-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: rgba(0, 0, 0, 0.3); + margin-bottom: 16px; + } + + h3 { + margin-bottom: 8px; + } + + .empty-text { + color: rgba(0, 0, 0, 0.6); + max-width: 400px; + } +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts new file mode 100644 index 000000000..8eb299cdf --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts @@ -0,0 +1,246 @@ +import { + Component, Inject, OnInit, inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators +} from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +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 { MatSortModule } from '@angular/material/sort'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { TranslateModule } from '@ngx-translate/core'; +import { SelectionModel } from '@angular/cdk/collections'; +import { VariableBundle, Variable } from '../../models/coding-job.model'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { VariableAnalysisItem } from '../../models/variable-analysis-item.model'; + +export interface VariableBundleGroupDialogData { + bundleGroup?: VariableBundle; + isEdit: boolean; +} + +@Component({ + selector: 'coding-box-variable-bundle-dialog', + templateUrl: './variable-bundle-dialog.component.html', + styleUrls: ['./variable-bundle-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + MatChipsModule, + MatTableModule, + MatCheckboxModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + TranslateModule + ] +}) +export class VariableBundleDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private backendService = inject(BackendService); + private appService = inject(AppService); + + bundleGroupForm!: FormGroup; + isLoading = false; + + // Variables + availableVariables: Variable[] = []; + selectedVariables = new SelectionModel(true, []); + displayedColumns: string[] = ['select', 'unitName', 'variableId']; + dataSource = new MatTableDataSource([]); + + // Variable analysis items + variableAnalysisItems: VariableAnalysisItem[] = []; + isLoadingVariableAnalysis = false; + totalVariableAnalysisRecords = 0; + variableAnalysisPageIndex = 0; + variableAnalysisPageSize = 10; + variableAnalysisPageSizeOptions = [5, 10, 25, 50]; + + // Filters + unitNameFilter = ''; + variableIdFilter = ''; + + // Selected variables table + selectedVariablesDataSource = new MatTableDataSource([]); + selectedVariablesDisplayedColumns: string[] = ['unitName', 'variableId', 'actions']; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: VariableBundleGroupDialogData + ) {} + + ngOnInit(): void { + this.initForm(); + this.loadVariableAnalysisItems(); + + if (this.data.bundleGroup?.variables) { + this.selectedVariablesDataSource.data = [...this.data.bundleGroup.variables]; + } + } + + initForm(): void { + this.bundleGroupForm = this.fb.group({ + name: [this.data.bundleGroup?.name || '', Validators.required], + description: [this.data.bundleGroup?.description || ''] + }); + } + + loadVariableAnalysisItems(page: number = 1, limit: number = 10): void { + this.isLoadingVariableAnalysis = true; + const workspaceId = this.appService.selectedWorkspaceId; + + if (!workspaceId) { + this.isLoadingVariableAnalysis = false; + return; + } + + this.backendService.getVariableAnalysis( + workspaceId, + page, + limit, + this.unitNameFilter || undefined, + this.variableIdFilter || 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.availableVariables = Array.from(uniqueVariables.values()); + this.dataSource.data = this.availableVariables; + + // Pre-select variables that are already in the bundle group + if (this.data.bundleGroup?.variables) { + this.data.bundleGroup.variables.forEach((variable: Variable) => { + const foundVariable = this.availableVariables.find( + v => v.unitName === variable.unitName && v.variableId === variable.variableId + ); + if (foundVariable) { + this.selectedVariables.select(foundVariable); + } + }); + } + + this.totalVariableAnalysisRecords = response.total; + this.variableAnalysisPageIndex = page - 1; + this.isLoadingVariableAnalysis = false; + }, + error: () => { + this.isLoadingVariableAnalysis = false; + } + }); + } + + onPageChange(event: PageEvent): void { + this.loadVariableAnalysisItems(event.pageIndex + 1, event.pageSize); + } + + applyFilter(): void { + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + clearFilters(): void { + this.unitNameFilter = ''; + this.variableIdFilter = ''; + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected(): boolean { + const numSelected = this.selectedVariables.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + masterToggle(): void { + if (this.isAllSelected()) { + 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}`; + } + + /** Add selected variables to the bundle group */ + addSelectedVariables(): void { + const currentVariables = this.selectedVariablesDataSource.data; + const newVariables = this.selectedVariables.selected.filter(variable => !currentVariables.some(v => v.unitName === variable.unitName && v.variableId === variable.variableId + ) + ); + + if (newVariables.length > 0) { + this.selectedVariablesDataSource.data = [...currentVariables, ...newVariables]; + this.selectedVariables.clear(); + } + } + + /** Remove a variable from the bundle group */ + removeVariable(variable: Variable): void { + const currentVariables = this.selectedVariablesDataSource.data; + const updatedVariables = currentVariables.filter(v => !(v.unitName === variable.unitName && v.variableId === variable.variableId) + ); + + this.selectedVariablesDataSource.data = updatedVariables; + } + + onSubmit(): void { + if (this.bundleGroupForm.invalid) { + return; + } + + const bundleGroup: VariableBundle = { + id: this.data.bundleGroup?.id || 0, + ...this.bundleGroupForm.value, + createdAt: this.data.bundleGroup?.createdAt || new Date(), + updatedAt: new Date(), + variables: this.selectedVariablesDataSource.data + }; + + this.dialogRef.close(bundleGroup); + } + + onCancel(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html new file mode 100644 index 000000000..d4b10b45e --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html @@ -0,0 +1,103 @@ +
+ + + @if (isLoading) { +
+ +
+ } + @if (!isLoading) { + + + + + + + + + + + + + + + + + + + + + Name + + {{element.name}} + + + + + Beschreibung + + {{element.description}} + + + + + Anzahl Variablen + + {{getVariableCount(element)}} + + + + + Erstellt am + + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktualisiert am + + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktionen + + + + + + + + @if (dataSource.data.length === 0) { +
+

Keine Variablenbündel vorhanden. Erstellen Sie ein neues Variablenbündel mit dem Button oben.

+
+ } + } +
diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss new file mode 100644 index 000000000..23d27280d --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss @@ -0,0 +1,79 @@ +.container { + width: 100%; + padding: 16px; +} + +.action-buttons { + margin-bottom: 16px; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 200px; +} + +.variable-bundle-table { + width: 100%; + margin-top: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-selectCheckbox { + flex: 0 0 60px; + } + + .mat-column-name { + flex: 1; + min-width: 150px; + } + + .mat-column-description { + flex: 2; + min-width: 200px; + } + + .mat-column-variableCount { + flex: 0 0 120px; + justify-content: center; + } + + .mat-column-createdAt, + .mat-column-updatedAt { + flex: 0 0 160px; + } + + .mat-column-actions { + flex: 0 0 100px; + justify-content: center; + } +} + +.no-data-message { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100px; + color: rgba(0, 0, 0, 0.6); + font-style: italic; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 4px; + margin-top: 16px; +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts new file mode 100644 index 000000000..8075fc96a --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts @@ -0,0 +1,233 @@ +import { + Component, OnInit, ViewChild, AfterViewInit, inject +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { + MatCell, MatCellDef, MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, + MatRow, MatRowDef, + MatTable, + MatTableDataSource, + MatTableModule +} from '@angular/material/table'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { SelectionModel } from '@angular/cdk/collections'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; +import { VariableBundle } from '../../models/coding-job.model'; +import { VariableBundleService } from '../../services/variable-bundle.service'; +import { VariableBundleDialogComponent } from '../variable-bundle-dialog/variable-bundle-dialog.component'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; + +@Component({ + selector: 'coding-box-variable-bundle-manager', + templateUrl: './variable-bundle-manager.component.html', + styleUrls: ['./variable-bundle-manager.component.scss'], + standalone: true, + imports: [ + CommonModule, + TranslateModule, + DatePipe, + SearchFilterComponent, + MatIcon, + MatHeaderCell, + MatCell, + MatHeaderRow, + MatRow, + MatProgressSpinner, + MatCheckbox, + MatTable, + MatTableModule, + MatAnchor, + MatHeaderCellDef, + MatCellDef, + MatHeaderRowDef, + MatRowDef, + MatColumnDef, + MatSortModule, + MatButton, + MatDialogModule, + MatTooltipModule, + MatIconButton + ] +}) +export class VariableBundleManagerComponent implements OnInit, AfterViewInit { + private variableBundleGroupService = inject(VariableBundleService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'variableCount', 'createdAt', 'updatedAt', 'actions']; + dataSource = new MatTableDataSource([]); + selection = new SelectionModel(true, []); + isLoading = false; + + @ViewChild(MatSort) sort!: MatSort; + + ngOnInit(): void { + this.loadVariableBundleGroups(); + } + + ngAfterViewInit(): void { + this.dataSource.sort = this.sort; + } + + loadVariableBundleGroups(): void { + this.isLoading = true; + + this.variableBundleGroupService.getBundleGroups().subscribe({ + next: bundleGroups => { + this.dataSource.data = bundleGroups; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.snackBar.open('Fehler beim Laden der Variablenbündel', 'Schließen', { duration: 3000 }); + } + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + isAllSelected(): boolean { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + isIndeterminate(): boolean { + return this.selection.selected.length > 0 && !this.isAllSelected(); + } + + masterToggle(): void { + if (this.isAllSelected()) { + this.selection.clear(); + } else { + this.dataSource.data.forEach(row => this.selection.select(row)); + } + } + + selectRow(row: VariableBundle): void { + this.selection.toggle(row); + } + + createVariableBundleGroup(): void { + const dialogRef = this.dialog.open(VariableBundleDialogComponent, { + width: '900px', + data: { + isEdit: false + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.variableBundleGroupService.createBundle(result).subscribe({ + next: newBundleGroup => { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${newBundleGroup.name}" wurde erstellt`, 'Schließen', { duration: 3000 }); + }, + error: () => { + this.snackBar.open('Fehler beim Erstellen des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + }); + } + + editVariableBundleGroup(bundleGroup: VariableBundle): void { + const dialogRef = this.dialog.open(VariableBundleDialogComponent, { + width: '900px', + data: { + bundleGroup, + isEdit: true + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.variableBundleGroupService.updateBundle(bundleGroup.id, result).subscribe({ + next: updatedBundleGroup => { + if (updatedBundleGroup) { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${updatedBundleGroup.name}" wurde aktualisiert`, 'Schließen', { duration: 3000 }); + } + }, + error: () => { + this.snackBar.open('Fehler beim Aktualisieren des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + }); + } + + deleteVariableBundleGroup(bundleGroup: VariableBundle): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: { + title: 'Variablenbündel löschen', + content: `Sind Sie sicher, dass Sie das Variablenbündel "${bundleGroup.name}" löschen möchten?`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.variableBundleGroupService.deleteBundle(bundleGroup.id).subscribe({ + next: success => { + if (success) { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${bundleGroup.name}" wurde gelöscht`, 'Schließen', { duration: 3000 }); + } + }, + error: () => { + this.snackBar.open('Fehler beim Löschen des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + }); + } + + deleteSelectedVariableBundleGroups(): void { + if (this.selection.selected.length === 0) { + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: { + title: 'Variablenbündel löschen', + content: `Sind Sie sicher, dass Sie ${this.selection.selected.length} ausgewählte Variablenbündel löschen möchten?`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const deletePromises = this.selection.selected.map(bundleGroup => this.variableBundleGroupService.deleteBundle(bundleGroup.id) + ); + + Promise.all(deletePromises).then(() => { + this.loadVariableBundleGroups(); + this.selection.clear(); + this.snackBar.open(`${this.selection.selected.length} Variablenbündel wurden gelöscht`, 'Schließen', { duration: 3000 }); + }).catch(() => { + this.snackBar.open('Fehler beim Löschen der Variablenbündel', 'Schließen', { duration: 3000 }); + }); + } + }); + } + + getVariableCount(bundleGroup: VariableBundle): number { + return bundleGroup.variables.length; + } +} 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 d53264549..1b38bc442 100644 --- a/apps/frontend/src/app/coding/models/coding-job.model.ts +++ b/apps/frontend/src/app/coding/models/coding-job.model.ts @@ -6,4 +6,20 @@ export interface CodingJob { createdAt: Date; updatedAt: Date; assignedCoders: number[]; + variables?: Variable[]; + variableBundles?: VariableBundle[]; +} + +export interface Variable { + unitName: string; + variableId: string; +} + +export interface VariableBundle { + id: number; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + variables: Variable[]; } diff --git a/apps/frontend/src/app/coding/services/coder.service.ts b/apps/frontend/src/app/coding/services/coder.service.ts index 4f861b217..6a8d09a2b 100644 --- a/apps/frontend/src/app/coding/services/coder.service.ts +++ b/apps/frontend/src/app/coding/services/coder.service.ts @@ -1,8 +1,10 @@ import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; +import { catchError, map } from 'rxjs/operators'; import { Coder } from '../models/coder.model'; import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; @Injectable({ providedIn: 'root' @@ -10,29 +12,23 @@ import { SERVER_URL } from '../../injection-tokens'; export class CoderService { private http = inject(HttpClient); private readonly serverUrl = inject(SERVER_URL); - - // Initialize with empty array + private appService = inject(AppService); private codersSubject = new BehaviorSubject([]); - /** - * Gets all coders (users with accessLevel 1) for the current workspace - */ getCoders(): Observable { - // Get the current workspace ID from localStorage - const workspaceId = localStorage.getItem('workspace_id'); - + const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { - console.error('No workspace ID found in localStorage'); + console.error('No workspace ID available'); return of([]); } - - // Fetch coders from the API - const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coders`; + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coders`; interface WorkspaceUser { userId: number; workspaceId: number; accessLevel: number; + username: string; } this.http.get<{ data: WorkspaceUser[], total: number }>(url).subscribe({ @@ -40,8 +36,8 @@ export class CoderService { // Map the workspace users with accessLevel 1 to Coder objects const coders: Coder[] = response.data.map(user => ({ id: user.userId, - name: `User ${user.userId}`, // Default name if user details not available - displayName: `Coder ${user.userId}`, // Default display name + name: user.username || `User ${user.userId}`, // Use username if available, otherwise fallback to default + displayName: user.username || `Coder ${user.userId}`, // Use username if available, otherwise fallback to default assignedJobs: [] })); @@ -58,19 +54,6 @@ export class CoderService { return this.codersSubject.asObservable(); } - /** - * Gets a coder by ID - * @param id The ID of the coder to get - */ - getCoderById(id: number): Observable { - const coder = this.codersSubject.value.find(c => c.id === id); - return of(coder); - } - - /** - * Creates a new coder - * @param coder The coder to create - */ createCoder(coder: Omit): Observable { const newCoder: Coder = { ...coder, @@ -83,11 +66,6 @@ export class CoderService { return of(newCoder); } - /** - * Updates an existing coder - * @param id The ID of the coder to update - * @param coder The updated coder data - */ updateCoder(id: number, coder: Partial): Observable { const coders = this.codersSubject.value; const index = coders.findIndex(c => c.id === id); @@ -132,32 +110,51 @@ export class CoderService { * @param jobId The ID of the coding job */ assignJob(coderId: number, jobId: number): Observable { - const coders = this.codersSubject.value; - const index = coders.findIndex(c => c.id === coderId); - - if (index === -1) { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); return of(undefined); } - const coder = coders[index]; - const assignedJobs = coder.assignedJobs || []; + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/assign/${coderId}`; - // Only add the job if it's not already assigned - if (!assignedJobs.includes(jobId)) { - const updatedCoder: Coder = { - ...coder, - assignedJobs: [...assignedJobs, jobId] - }; + return this.http.post<{ success: boolean }>(url, {}).pipe( + map(() => { + // Update the local state after successful assignment + const coders = this.codersSubject.value; + const index = coders.findIndex(c => c.id === coderId); - const updatedCoders = [...coders]; - updatedCoders[index] = updatedCoder; + if (index === -1) { + return undefined; + } - this.codersSubject.next(updatedCoders); + const coder = coders[index]; + const assignedJobs = coder.assignedJobs || []; - return of(updatedCoder); - } + // Only add the job if it's not already assigned + if (!assignedJobs.includes(jobId)) { + const updatedCoder: Coder = { + ...coder, + assignedJobs: [...assignedJobs, jobId] + }; + + const updatedCoders = [...coders]; + updatedCoders[index] = updatedCoder; + + this.codersSubject.next(updatedCoders); - return of(coder); + return updatedCoder; + } + + return coder; + }), + catchError(error => { + console.error(`Error assigning job ${jobId} to coder ${coderId}:`, error); + return of(undefined); + }) + ); } /** @@ -166,27 +163,46 @@ export class CoderService { * @param jobId The ID of the coding job */ unassignJob(coderId: number, jobId: number): Observable { - const coders = this.codersSubject.value; - const index = coders.findIndex(c => c.id === coderId); - - if (index === -1) { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); return of(undefined); } - const coder = coders[index]; - const assignedJobs = coder.assignedJobs || []; + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/unassign/${coderId}`; - const updatedCoder: Coder = { - ...coder, - assignedJobs: assignedJobs.filter(id => id !== jobId) - }; + return this.http.delete<{ success: boolean }>(url).pipe( + map(() => { + // Update the local state after successful unassignment + const coders = this.codersSubject.value; + const index = coders.findIndex(c => c.id === coderId); - const updatedCoders = [...coders]; - updatedCoders[index] = updatedCoder; + if (index === -1) { + return undefined; + } - this.codersSubject.next(updatedCoders); + const coder = coders[index]; + const assignedJobs = coder.assignedJobs || []; - return of(updatedCoder); + const updatedCoder: Coder = { + ...coder, + assignedJobs: assignedJobs.filter(id => id !== jobId) + }; + + const updatedCoders = [...coders]; + updatedCoders[index] = updatedCoder; + + this.codersSubject.next(updatedCoders); + + return updatedCoder; + }), + catchError(error => { + console.error(`Error unassigning job ${jobId} from coder ${coderId}:`, error); + return of(undefined); + }) + ); } /** @@ -194,10 +210,67 @@ export class CoderService { * @param jobId The ID of the coding job */ getCodersByJobId(jobId: number): Observable { - const coders = this.codersSubject.value.filter( - coder => coder.assignedJobs?.includes(jobId) + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); + return of([]); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/coders`; + + interface WorkspaceUser { + userId: number; + workspaceId: number; + accessLevel: number; + username: string; + } + + return this.http.get<{ data: WorkspaceUser[], total: number }>(url).pipe( + map(response => { + // Map WorkspaceUser objects to Coder objects + const fetchedCoders: Coder[] = response.data.map(user => ({ + id: user.userId, + name: user.username || `User ${user.userId}`, + displayName: user.username || `Coder ${user.userId}`, + assignedJobs: [jobId] + })); + + // Merge with existing coders to maintain other properties + const existingCoders = this.codersSubject.value; + const mergedCoders = [...existingCoders]; + + fetchedCoders.forEach(fetchedCoder => { + const index = mergedCoders.findIndex(c => c.id === fetchedCoder.id); + if (index !== -1) { + // Update existing coder + mergedCoders[index] = { + ...mergedCoders[index], + ...fetchedCoder, + assignedJobs: [...(mergedCoders[index].assignedJobs || []), jobId] + }; + } else { + // Add new coder + mergedCoders.push(fetchedCoder); + } + }); + + // Update the subject with the merged coders + this.codersSubject.next(mergedCoders); + + return fetchedCoders; + }), + catchError(error => { + console.error(`Error fetching coders for job ${jobId}:`, error); + + // Fallback to local data if API call fails + const coders = this.codersSubject.value.filter( + coder => coder.assignedJobs?.includes(jobId) + ); + return of(coders); + }) ); - return of(coders); } /** diff --git a/apps/frontend/src/app/coding/services/coding-job.service.ts b/apps/frontend/src/app/coding/services/coding-job.service.ts new file mode 100644 index 000000000..e1ad25be3 --- /dev/null +++ b/apps/frontend/src/app/coding/services/coding-job.service.ts @@ -0,0 +1,252 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + BehaviorSubject, Observable, catchError, map, of, tap +} from 'rxjs'; +import { CodingJob } from '../models/coding-job.model'; +import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; + +@Injectable({ + providedIn: 'root' +}) +export class CodingJobService { + private http = inject(HttpClient); + private readonly serverUrl = inject(SERVER_URL); + private appService = inject(AppService); + private codingJobsSubject = new BehaviorSubject([]); + + /** + * Gets all coding jobs for the current workspace + */ + getCodingJobs(): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of([]); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs`; + + this.http.get<{ data: CodingJob[], total: number }>(url).subscribe({ + next: response => { + // Map the response data to CodingJob objects + const codingJobs: CodingJob[] = response.data.map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + })); + + // Update the subject with the fetched coding jobs + this.codingJobsSubject.next(codingJobs); + }, + error: () => { + // Keep the current value in case of error + } + }); + + // Return the observable from the subject + return this.codingJobsSubject.asObservable(); + } + + /** + * Gets a coding job by ID + * @param id The ID of the coding job + */ + getCodingJob(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.get(url).pipe( + map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + })), + catchError(() => of(undefined)) + ); + } + + /** + * Creates a new coding job + * @param job The coding job to create + */ + createCodingJob(job: Omit): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs`; + + return this.http.post(url, job).pipe( + map(newJob => ({ + ...newJob, + createdAt: new Date(newJob.createdAt), + updatedAt: new Date(newJob.updatedAt) + })), + tap(newJob => { + if (newJob) { + const currentJobs = this.codingJobsSubject.value; + this.codingJobsSubject.next([...currentJobs, newJob]); + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Updates a coding job + * @param id The ID of the coding job to update + * @param job The updated coding job data + */ + updateCodingJob(id: number, job: Partial): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.put(url, job).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === id); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Deletes a coding job + * @param id The ID of the coding job to delete + */ + deleteCodingJob(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(false); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.delete<{ success: boolean }>(url).pipe( + map(response => response.success), + tap(success => { + if (success) { + const currentJobs = this.codingJobsSubject.value; + this.codingJobsSubject.next(currentJobs.filter(job => job.id !== id)); + } + }), + catchError(() => of(false)) + ); + } + + /** + * Assigns a coder to a coding job + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + assignCoder(codingJobId: number, coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${codingJobId}/assign/${coderId}`; + + return this.http.post(url, {}).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === codingJobId); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Unassigns a coder from a coding job + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + unassignCoder(codingJobId: number, coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${codingJobId}/assign/${coderId}`; + + return this.http.delete(url).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === codingJobId); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Gets all coding jobs assigned to a coder + * @param coderId The ID of the coder + */ + getCodingJobsByCoder(coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); + return of([]); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coders/${coderId}/coding-jobs`; + + return this.http.get<{ data: CodingJob[] }>(url).pipe( + map(response => response.data.map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + }))), + catchError(error => { + console.error(`Error fetching coding jobs for coder ${coderId}:`, error); + return of([]); + }) + ); + } +} diff --git a/apps/frontend/src/app/coding/services/variable-bundle.service.ts b/apps/frontend/src/app/coding/services/variable-bundle.service.ts new file mode 100644 index 000000000..6e083b634 --- /dev/null +++ b/apps/frontend/src/app/coding/services/variable-bundle.service.ts @@ -0,0 +1,313 @@ +import { Injectable, inject } from '@angular/core'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { catchError, map, take } from 'rxjs/operators'; +import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; +import { VariableBundle, Variable } from '../models/coding-job.model'; + +@Injectable({ + providedIn: 'root' +}) +export class VariableBundleService { + private http = inject(HttpClient); + private readonly serverUrl = inject(SERVER_URL); + private appService = inject(AppService); + private bundlesSubject = new BehaviorSubject([]); + + private sampleBundles: VariableBundle[] = [ + { + id: 1, + name: 'Mathematische Fähigkeiten', + description: 'Variablen zur Bewertung mathematischer Fähigkeiten', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-15'), + variables: [ + { unitName: 'math101', variableId: 'addition' }, + { unitName: 'math101', variableId: 'subtraction' }, + { unitName: 'math102', variableId: 'multiplication' } + ] + }, + { + id: 2, + name: 'Sprachliche Fähigkeiten', + description: 'Variablen zur Bewertung sprachlicher Fähigkeiten', + createdAt: new Date('2023-02-01'), + updatedAt: new Date('2023-02-15'), + variables: [ + { unitName: 'lang101', variableId: 'grammar' }, + { unitName: 'lang101', variableId: 'vocabulary' }, + { unitName: 'lang102', variableId: 'comprehension' } + ] + } + ]; + + constructor() { + this.bundlesSubject.next(this.sampleBundles); + } + + getBundleGroups(): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of([]); + } + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle`; + + return this.http.get<{ data: VariableBundle[], total: number }>(url).pipe( + map(response => { + const bundles = response.data; + this.bundlesSubject.next(bundles); + + return bundles; + }), + catchError(() => this.bundlesSubject.asObservable().pipe( + take(1) + ) + ) + ); + } + + getBundleById(id: number): Observable { + const bundles = this.bundlesSubject.value; + const bundle = bundles.find(b => b.id === id); + return of(bundle); + } + + createBundle(bundle: Omit): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of({ + ...bundle, + id: this.getNextId() + } as VariableBundle); + } + + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle`; + + return this.http.post(url, bundle).pipe( + map(newBundle => { + const updatedBundles = [...this.bundlesSubject.value, newBundle]; + this.bundlesSubject.next(updatedBundles); + + return newBundle; + }), + catchError(() => { + const newBundle: VariableBundle = { + ...bundle, + id: this.getNextId() + }; + + const updatedBundle = [...this.bundlesSubject.value, newBundle]; + this.bundlesSubject.next(updatedBundle); + + return of(newBundle); + }) + ); + } + + updateBundle(id: number, bundle: Partial): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${id}`; + + const updateData = { + ...bundle, + updatedAt: new Date() + }; + + return this.http.put(url, updateData).pipe( + map(updatedBundleGroup => { + // Update the local state with the updated bundle group + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(b => b.id === id); + + if (index !== -1) { + const updatedBundleGroups = [...bundles]; + updatedBundleGroups[index] = updatedBundleGroup; + this.bundlesSubject.next(updatedBundleGroups); + } + + return updatedBundleGroup; + }), + catchError(() => { + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(b => b.id === id); + + if (index === -1) { + return of(undefined); + } + + const updatedBundle: VariableBundle = { + ...bundles[index], + ...bundle, + updatedAt: new Date() + }; + + const updatedBundleGroups = [...bundles]; + updatedBundleGroups[index] = updatedBundle; + + this.bundlesSubject.next(updatedBundleGroups); + + return of(updatedBundle); + }) + ); + } + + deleteBundle(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(false); + } + + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${id}`; + + return this.http.delete<{ success: boolean }>(url).pipe( + map(response => { + if (response.success) { + const bundleGroups = this.bundlesSubject.value; + const updatedBundles = bundleGroups.filter(group => group.id !== id); + this.bundlesSubject.next(updatedBundles); + } + + return response.success; + }), + catchError(() => { + const bundles = this.bundlesSubject.value; + const updatedBundles = bundles.filter(b => b.id !== id); + + if (updatedBundles.length === bundles.length) { + return of(false); + } + + this.bundlesSubject.next(updatedBundles); + + return of(true); + }) + ); + } + + addVariableToBundle(bundleId: number, variable: Variable): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${bundleId}/variables`; + + return this.http.post(url, variable).pipe( + map(updatedBundle => { + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(b => b.id === bundleId); + + if (index !== -1) { + const updatedBundles = [...bundles]; + updatedBundles[index] = updatedBundle; + this.bundlesSubject.next(updatedBundles); + } + + return updatedBundle; + }), + catchError(() => { + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(b => b.id === bundleId); + + if (index === -1) { + return of(undefined); + } + + const bundle = bundles[index]; + + const variableExists = bundle.variables.some( + v => v.unitName === variable.unitName && v.variableId === variable.variableId + ); + + if (variableExists) { + return of(bundle); + } + + const updatedBundle: VariableBundle = { + ...bundle, + variables: [...bundle.variables, variable as Variable], + updatedAt: new Date() + }; + + const updatedBundles = [...bundles]; + updatedBundles[index] = updatedBundle; + + this.bundlesSubject.next(updatedBundles); + + return of(updatedBundle); + }) + ); + } + + removeVariableFromBundle(groupId: number, variable: Variable): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const encodedUnitName = encodeURIComponent((variable as Variable).unitName); + const encodedVariableId = encodeURIComponent((variable as Variable).variableId); + + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${groupId}/variables/${encodedUnitName}/${encodedVariableId}`; + + return this.http.delete(url).pipe( + map(updatedBundle => { + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(group => group.id === groupId); + + if (index !== -1) { + const updatedBundles = [...bundles]; + updatedBundles[index] = updatedBundle; + this.bundlesSubject.next(updatedBundles); + } + + return updatedBundle; + }), + catchError(() => { + const bundles = this.bundlesSubject.value; + const index = bundles.findIndex(group => group.id === groupId); + + if (index === -1) { + return of(undefined); + } + + const bundle = bundles[index]; + + const updatedVariables = bundle.variables.filter( + v => !(v.unitName === (variable as Variable).unitName && v.variableId === (variable as Variable).variableId) + ); + + const updatedBundle: VariableBundle = { + ...bundle, + variables: updatedVariables, + updatedAt: new Date() + }; + + const updatedBundles = [...bundles]; + updatedBundles[index] = updatedBundle; + + this.bundlesSubject.next(updatedBundles); + + return of(updatedBundle); + }) + ); + } + + private getNextId(): number { + const bundleGroups = this.bundlesSubject.value; + return bundleGroups.length > 0 ? + Math.max(...bundleGroups.map(group => group.id)) + 1 : + 1; + } +} diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 86e314cc7..4a1750499 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.11.1'" + [appVersion]="'0.12.0'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/apps/frontend/src/app/components/home/home.component.ts b/apps/frontend/src/app/components/home/home.component.ts index 2e5618d5e..fd9aedcc5 100755 --- a/apps/frontend/src/app/components/home/home.component.ts +++ b/apps/frontend/src/app/components/home/home.component.ts @@ -7,12 +7,13 @@ import { MatButtonModule } from '@angular/material/button'; import { ReactiveFormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from '../../services/app.service'; import { AppInfoComponent } from '../app-info/app-info.component'; import { UserWorkspacesAreaComponent } from '../../workspace/components/user-workspaces-area/user-workspaces-area.component'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; +import { BackendService } from '../../services/backend.service'; @Component({ selector: 'coding-box-home', @@ -32,10 +33,13 @@ import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace export class HomeComponent implements OnInit, OnDestroy { readonly appService = inject(AppService); private route = inject(ActivatedRoute); + private router = inject(Router); private snackBar = inject(MatSnackBar); + private backendService = inject(BackendService); workspaces: WorkspaceFullDto[] = []; authData = AppService.defaultAuthData; + private isCoderChecked = false; private authSubscription?: Subscription; @@ -45,6 +49,11 @@ export class HomeComponent implements OnInit, OnDestroy { if (authData) { this.authData = authData; this.workspaces = authData.workspaces; + + // Check if user is a coder and redirect if needed + if (!this.isCoderChecked && authData.userId > 0) { + this.checkIfUserIsCoder(authData.userId); + } } }); @@ -55,6 +64,24 @@ export class HomeComponent implements OnInit, OnDestroy { }); } + private checkIfUserIsCoder(userId: number): void { + this.isCoderChecked = true; + + if (!this.workspaces || this.workspaces.length === 0) { + return; + } + + const firstWorkspaceId = this.workspaces[0].id; + + this.backendService.getWorkspaceUsers(firstWorkspaceId).subscribe(response => { + const currentUser = response.data.find(user => user.userId === userId); + + if (currentUser && currentUser.accessLevel === 1) { + this.router.navigate(['/coding']); + } + }); + } + /** * Shows an error message based on the error code * @param errorCode The error code from the query parameters diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 1760ede92..76b639938 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -9,6 +9,7 @@ import { UnitTagDto } from 'api-dto/unit-tags/unit-tag.dto'; import { CreateUnitTagDto } from 'api-dto/unit-tags/create-unit-tag.dto'; import { CreateWorkspaceDto } from 'api-dto/workspaces/create-workspace-dto'; import { PaginatedWorkspacesDto } from 'api-dto/workspaces/paginated-workspaces-dto'; +import { VariableBundle } from '../coding/models/coding-job.model'; import { AppService } from './app.service'; import { TestGroupsInfoDto } from '../../../../../api-dto/files/test-groups-info.dto'; import { SERVER_URL } from '../injection-tokens'; @@ -823,4 +824,14 @@ export class BackendService { 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); + } } diff --git a/apps/frontend/src/assets/images/IQB-LogoA.png b/apps/frontend/src/assets/images/IQB-LogoA.png old mode 100755 new mode 100644 index 8a1c70ed8..f0cef34ce Binary files a/apps/frontend/src/assets/images/IQB-LogoA.png and b/apps/frontend/src/assets/images/IQB-LogoA.png differ diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql new file mode 100644 index 000000000..aa1c9050a --- /dev/null +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -0,0 +1,16 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE "public"."variable_bundle" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" TEXT, + "variables" JSONB NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_variable_bundle_workspace_id" ON "public"."variable_bundle" ("workspace_id"); + +-- rollback DROP TABLE IF EXISTS "public"."variable_bundle"; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 8d861d409..0e663988d 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -20,4 +20,5 @@ + diff --git a/package-lock.json b/package-lock.json index 0601473a4..ffdd755de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", @@ -105,6 +105,13 @@ "typescript": "5.8.3" } }, + "node_modules/@adobe/css-tools": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -11392,6 +11399,8 @@ "sass-loader": "^16.0.4", "source-map-loader": "^5.0.0", "style-loader": "^3.3.0", + "stylus": "^0.64.0", + "stylus-loader": "^7.1.0", "terser-webpack-plugin": "^5.3.3", "ts-loader": "^9.3.1", "tsconfig-paths-webpack-plugin": "4.0.0", @@ -29121,6 +29130,88 @@ "postcss": "^8.4.31" } }, + "node_modules/stylus": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.64.0.tgz", + "integrity": "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "~4.3.3", + "debug": "^4.3.2", + "glob": "^10.4.5", + "sax": "~1.4.1", + "source-map": "^0.7.3" + }, + "bin": { + "stylus": "bin/stylus" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://opencollective.com/stylus" + } + }, + "node_modules/stylus-loader": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/stylus-loader/-/stylus-loader-7.1.3.tgz", + "integrity": "sha512-TY0SKwiY7D2kMd3UxaWKSf3xHF0FFN/FAfsSqfrhxRT/koXTwffq2cgEWDkLQz7VojMu7qEEHt5TlMjkPx9UDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.12", + "normalize-path": "^3.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "stylus": ">=0.52.4", + "webpack": "^5.0.0" + } + }, + "node_modules/stylus/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stylus/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 320567170..0ef4f3a00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.11.1", + "version": "0.12.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {