diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 99a03bd..8e31890 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -53,6 +53,7 @@ model User { authProvider AuthProvider[] tenantMemberships TenantMembership[] auditLogs AuditLog[] + expertProfile ExpertProfile? @@map("users") } @@ -190,3 +191,137 @@ model AuditLog { @@index([createdAt]) @@map("audit_logs") } + +// -------------------------------------------------------- +// ExpertProfile - Experten-Profil (1:1 mit User) +// -------------------------------------------------------- +model ExpertProfile { + id String @id @default(uuid()) @db.Uuid + userId String @unique @map("user_id") @db.Uuid + + // Skills als Tag-Array + skills String[] @default([]) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + experiences ExpertExperience[] + languages ExpertLanguage[] + projects ExpertProject[] + certifications ExpertCertification[] + attachments ExpertAttachment[] + + @@map("expert_profiles") +} + +// -------------------------------------------------------- +// ExpertExperience - Erfahrung / Expertise-Bereiche +// -------------------------------------------------------- +model ExpertExperience { + id String @id @default(uuid()) @db.Uuid + expertProfileId String @map("expert_profile_id") @db.Uuid + area String @db.VarChar(200) // z.B. "IT Infrastruktur" + years Int // Jahre Erfahrung + level String? @db.VarChar(50) // Experte, Fortgeschritten, Grundkenntnisse + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade) + + @@map("expert_experiences") +} + +// -------------------------------------------------------- +// ExpertLanguage - Sprachen +// -------------------------------------------------------- +model ExpertLanguage { + id String @id @default(uuid()) @db.Uuid + expertProfileId String @map("expert_profile_id") @db.Uuid + language String @db.VarChar(100) // z.B. "Deutsch" + level String @db.VarChar(20) // Muttersprache, C2, C1, B2, B1, A2, A1 + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade) + + @@map("expert_languages") +} + +// -------------------------------------------------------- +// ExpertProject - Projekthistorie +// -------------------------------------------------------- +model ExpertProject { + id String @id @default(uuid()) @db.Uuid + expertProfileId String @map("expert_profile_id") @db.Uuid + + // Zeitraum + fromMonth Int @map("from_month") // 1-12 + fromYear Int @map("from_year") // z.B. 2023 + toMonth Int? @map("to_month") // null wenn isCurrent + toYear Int? @map("to_year") + isCurrent Boolean @default(false) @map("is_current") + + // Details + role String @db.VarChar(200) // Taetigkeit + tasks String? @db.Text // Aufgaben (max 1500 Zeichen im DTO) + company String? @db.VarChar(200) // Firma + companySize String? @map("company_size") @db.VarChar(20) // "1-10", "11-50", etc. + industry String? @db.VarChar(200) // Branche + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade) + + @@index([expertProfileId, fromYear, fromMonth]) + @@map("expert_projects") +} + +// -------------------------------------------------------- +// ExpertCertification - Zertifizierungen +// -------------------------------------------------------- +model ExpertCertification { + id String @id @default(uuid()) @db.Uuid + expertProfileId String @map("expert_profile_id") @db.Uuid + + title String @db.VarChar(300) // Titel + issuingBody String @map("issuing_body") @db.VarChar(300) // Zertifizierungsstelle + website String? @db.VarChar(500) // URL + issueYear Int @map("issue_year") // Ausstellungsjahr + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade) + + @@map("expert_certifications") +} + +// -------------------------------------------------------- +// ExpertAttachment - Profilanlagen (Dateien als Base64) +// -------------------------------------------------------- +model ExpertAttachment { + id String @id @default(uuid()) @db.Uuid + expertProfileId String @map("expert_profile_id") @db.Uuid + + filename String @db.VarChar(255) + mimetype String @db.VarChar(100) + size Int // Groesse in Bytes + data String @db.Text // Base64-Daten + + createdAt DateTime @default(now()) @map("created_at") + + // Relationen + expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade) + + @@map("expert_attachments") +} diff --git a/packages/core-service/prisma/migrations/20260309100000_add_expert_profile/migration.sql b/packages/core-service/prisma/migrations/20260309100000_add_expert_profile/migration.sql new file mode 100644 index 0000000..4bbac07 --- /dev/null +++ b/packages/core-service/prisma/migrations/20260309100000_add_expert_profile/migration.sql @@ -0,0 +1,106 @@ +-- CreateTable: expert_profiles +CREATE TABLE "expert_profiles" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "skills" TEXT[] DEFAULT ARRAY[]::TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "expert_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: expert_experiences +CREATE TABLE "expert_experiences" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "expert_profile_id" UUID NOT NULL, + "area" VARCHAR(200) NOT NULL, + "years" INTEGER NOT NULL, + "level" VARCHAR(50), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "expert_experiences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: expert_languages +CREATE TABLE "expert_languages" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "expert_profile_id" UUID NOT NULL, + "language" VARCHAR(100) NOT NULL, + "level" VARCHAR(20) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "expert_languages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: expert_projects +CREATE TABLE "expert_projects" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "expert_profile_id" UUID NOT NULL, + "from_month" INTEGER NOT NULL, + "from_year" INTEGER NOT NULL, + "to_month" INTEGER, + "to_year" INTEGER, + "is_current" BOOLEAN NOT NULL DEFAULT false, + "role" VARCHAR(200) NOT NULL, + "tasks" TEXT, + "company" VARCHAR(200), + "company_size" VARCHAR(20), + "industry" VARCHAR(200), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "expert_projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: expert_certifications +CREATE TABLE "expert_certifications" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "expert_profile_id" UUID NOT NULL, + "title" VARCHAR(300) NOT NULL, + "issuing_body" VARCHAR(300) NOT NULL, + "website" VARCHAR(500), + "issue_year" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "expert_certifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: expert_attachments +CREATE TABLE "expert_attachments" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "expert_profile_id" UUID NOT NULL, + "filename" VARCHAR(255) NOT NULL, + "mimetype" VARCHAR(100) NOT NULL, + "size" INTEGER NOT NULL, + "data" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "expert_attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "expert_profiles_user_id_key" ON "expert_profiles"("user_id"); + +-- CreateIndex +CREATE INDEX "expert_projects_expert_profile_id_from_year_from_month_idx" ON "expert_projects"("expert_profile_id", "from_year", "from_month"); + +-- AddForeignKey +ALTER TABLE "expert_profiles" ADD CONSTRAINT "expert_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expert_experiences" ADD CONSTRAINT "expert_experiences_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expert_languages" ADD CONSTRAINT "expert_languages_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expert_projects" ADD CONSTRAINT "expert_projects_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expert_certifications" ADD CONSTRAINT "expert_certifications_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "expert_attachments" ADD CONSTRAINT "expert_attachments_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts index c86c293..09d531b 100644 --- a/packages/core-service/src/app.module.ts +++ b/packages/core-service/src/app.module.ts @@ -9,6 +9,7 @@ import { RedisModule } from './redis/redis.module'; import { AuthModule } from './core/auth/auth.module'; import { UsersModule } from './core/users/users.module'; import { TenantsModule } from './core/tenants/tenants.module'; +import { ExpertProfileModule } from './core/expert-profile/expert-profile.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { validateConfig } from './config/env.validation'; @@ -40,6 +41,7 @@ import { validateConfig } from './config/env.validation'; AuthModule, UsersModule, TenantsModule, + ExpertProfileModule, ], providers: [ // Global Guards: Alle Routen sind standardmaessig geschuetzt diff --git a/packages/core-service/src/core/expert-profile/dto/create-certification.dto.ts b/packages/core-service/src/core/expert-profile/dto/create-certification.dto.ts new file mode 100644 index 0000000..de3593f --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/create-certification.dto.ts @@ -0,0 +1,29 @@ +import { IsString, IsInt, IsOptional, IsNotEmpty, MaxLength, Min, Max, IsUrl } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCertificationDto { + @ApiProperty({ example: 'AWS Solutions Architect Professional' }) + @IsString() + @IsNotEmpty({ message: 'Titel ist erforderlich' }) + @MaxLength(300) + title!: string; + + @ApiProperty({ example: 'Amazon Web Services' }) + @IsString() + @IsNotEmpty({ message: 'Zertifizierungsstelle ist erforderlich' }) + @MaxLength(300) + issuingBody!: string; + + @ApiProperty({ example: 'https://aws.amazon.com/certification/', required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + @IsUrl({}, { message: 'Bitte eine gültige URL angeben' }) + website?: string; + + @ApiProperty({ example: 2024, description: 'Ausstellungsjahr' }) + @IsInt() + @Min(1970) + @Max(2100) + issueYear!: number; +} diff --git a/packages/core-service/src/core/expert-profile/dto/create-experience.dto.ts b/packages/core-service/src/core/expert-profile/dto/create-experience.dto.ts new file mode 100644 index 0000000..23c5df1 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/create-experience.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsInt, IsOptional, MaxLength, Min, Max, IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateExperienceDto { + @ApiProperty({ example: 'IT Infrastruktur' }) + @IsString() + @MaxLength(200) + area!: string; + + @ApiProperty({ example: 10 }) + @IsInt() + @Min(0, { message: 'Jahre dürfen nicht negativ sein' }) + @Max(60, { message: 'Maximal 60 Jahre Erfahrung' }) + years!: number; + + @ApiProperty({ + example: 'Experte', + required: false, + enum: ['Experte', 'Fortgeschritten', 'Grundkenntnisse'], + }) + @IsOptional() + @IsString() + @IsIn(['Experte', 'Fortgeschritten', 'Grundkenntnisse']) + level?: string; +} diff --git a/packages/core-service/src/core/expert-profile/dto/create-language.dto.ts b/packages/core-service/src/core/expert-profile/dto/create-language.dto.ts new file mode 100644 index 0000000..200a54e --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/create-language.dto.ts @@ -0,0 +1,17 @@ +import { IsString, MaxLength, IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateLanguageDto { + @ApiProperty({ example: 'Deutsch' }) + @IsString() + @MaxLength(100) + language!: string; + + @ApiProperty({ + example: 'Muttersprache', + enum: ['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1'], + }) + @IsString() + @IsIn(['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1']) + level!: string; +} diff --git a/packages/core-service/src/core/expert-profile/dto/create-project.dto.ts b/packages/core-service/src/core/expert-profile/dto/create-project.dto.ts new file mode 100644 index 0000000..ae9e152 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/create-project.dto.ts @@ -0,0 +1,82 @@ +import { + IsString, + IsInt, + IsOptional, + IsBoolean, + IsNotEmpty, + MaxLength, + Min, + Max, + IsIn, + ValidateIf, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateProjectDto { + @ApiProperty({ example: 3, description: 'Startmonat (1-12)' }) + @IsInt() + @Min(1) + @Max(12) + fromMonth!: number; + + @ApiProperty({ example: 2023, description: 'Startjahr' }) + @IsInt() + @Min(1970) + @Max(2100) + fromYear!: number; + + @ApiProperty({ example: 6, required: false, description: 'Endmonat (1-12)' }) + @IsOptional() + @ValidateIf((o: CreateProjectDto) => !o.isCurrent) + @IsInt() + @Min(1) + @Max(12) + toMonth?: number; + + @ApiProperty({ example: 2024, required: false, description: 'Endjahr' }) + @IsOptional() + @ValidateIf((o: CreateProjectDto) => !o.isCurrent) + @IsInt() + @Min(1970) + @Max(2100) + toYear?: number; + + @ApiProperty({ example: false, required: false, description: 'Projekt läuft noch' }) + @IsOptional() + @IsBoolean() + isCurrent?: boolean; + + @ApiProperty({ example: 'Senior DevOps Engineer' }) + @IsString() + @IsNotEmpty({ message: 'Tätigkeit ist erforderlich' }) + @MaxLength(200) + role!: string; + + @ApiProperty({ example: 'Aufbau und Betrieb der Kubernetes-Infrastruktur', required: false }) + @IsOptional() + @IsString() + @MaxLength(1500, { message: 'Aufgaben dürfen maximal 1500 Zeichen lang sein' }) + tasks?: string; + + @ApiProperty({ example: 'Xinion GmbH', required: false }) + @IsOptional() + @IsString() + @MaxLength(200) + company?: string; + + @ApiProperty({ + example: '51-200', + required: false, + enum: ['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+'], + }) + @IsOptional() + @IsString() + @IsIn(['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+']) + companySize?: string; + + @ApiProperty({ example: 'IT-Dienstleistung', required: false }) + @IsOptional() + @IsString() + @MaxLength(200) + industry?: string; +} diff --git a/packages/core-service/src/core/expert-profile/dto/update-certification.dto.ts b/packages/core-service/src/core/expert-profile/dto/update-certification.dto.ts new file mode 100644 index 0000000..863792d --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/update-certification.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsInt, IsOptional, MaxLength, Min, Max, IsUrl } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateCertificationDto { + @ApiProperty({ example: 'AWS Solutions Architect Professional', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + title?: string; + + @ApiProperty({ example: 'Amazon Web Services', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + issuingBody?: string; + + @ApiProperty({ example: 'https://aws.amazon.com/certification/', required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + @IsUrl({}, { message: 'Bitte eine gültige URL angeben' }) + website?: string; + + @ApiProperty({ example: 2024, required: false }) + @IsOptional() + @IsInt() + @Min(1970) + @Max(2100) + issueYear?: number; +} diff --git a/packages/core-service/src/core/expert-profile/dto/update-project.dto.ts b/packages/core-service/src/core/expert-profile/dto/update-project.dto.ts new file mode 100644 index 0000000..4510077 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/update-project.dto.ts @@ -0,0 +1,79 @@ +import { + IsString, + IsInt, + IsOptional, + IsBoolean, + MaxLength, + Min, + Max, + IsIn, + ValidateIf, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateProjectDto { + @ApiProperty({ example: 3, required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(12) + fromMonth?: number; + + @ApiProperty({ example: 2023, required: false }) + @IsOptional() + @IsInt() + @Min(1970) + @Max(2100) + fromYear?: number; + + @ApiProperty({ example: 6, required: false }) + @IsOptional() + @ValidateIf((o: UpdateProjectDto) => !o.isCurrent) + @IsInt() + @Min(1) + @Max(12) + toMonth?: number; + + @ApiProperty({ example: 2024, required: false }) + @IsOptional() + @ValidateIf((o: UpdateProjectDto) => !o.isCurrent) + @IsInt() + @Min(1970) + @Max(2100) + toYear?: number; + + @ApiProperty({ example: false, required: false }) + @IsOptional() + @IsBoolean() + isCurrent?: boolean; + + @ApiProperty({ example: 'Senior DevOps Engineer', required: false }) + @IsOptional() + @IsString() + @MaxLength(200) + role?: string; + + @ApiProperty({ example: 'Aufbau der K8s-Infrastruktur', required: false }) + @IsOptional() + @IsString() + @MaxLength(1500, { message: 'Aufgaben dürfen maximal 1500 Zeichen lang sein' }) + tasks?: string; + + @ApiProperty({ example: 'Xinion GmbH', required: false }) + @IsOptional() + @IsString() + @MaxLength(200) + company?: string; + + @ApiProperty({ example: '51-200', required: false }) + @IsOptional() + @IsString() + @IsIn(['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+']) + companySize?: string; + + @ApiProperty({ example: 'IT-Dienstleistung', required: false }) + @IsOptional() + @IsString() + @MaxLength(200) + industry?: string; +} diff --git a/packages/core-service/src/core/expert-profile/dto/update-skills.dto.ts b/packages/core-service/src/core/expert-profile/dto/update-skills.dto.ts new file mode 100644 index 0000000..c1802b6 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/update-skills.dto.ts @@ -0,0 +1,14 @@ +import { IsArray, IsString, MaxLength, ArrayMaxSize } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateSkillsDto { + @ApiProperty({ + example: ['Kubernetes', 'Docker', 'AWS', 'Terraform'], + description: 'Komplettes Skills-Array (ersetzt vorhandene Skills)', + }) + @IsArray() + @IsString({ each: true }) + @MaxLength(100, { each: true, message: 'Jeder Skill darf maximal 100 Zeichen lang sein' }) + @ArrayMaxSize(50, { message: 'Maximal 50 Skills erlaubt' }) + skills!: string[]; +} diff --git a/packages/core-service/src/core/expert-profile/dto/upload-attachment.dto.ts b/packages/core-service/src/core/expert-profile/dto/upload-attachment.dto.ts new file mode 100644 index 0000000..042dc9a --- /dev/null +++ b/packages/core-service/src/core/expert-profile/dto/upload-attachment.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsInt, IsNotEmpty, MaxLength, Min, Max } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UploadAttachmentDto { + @ApiProperty({ example: 'AWS-Zertifikat.pdf' }) + @IsString() + @IsNotEmpty({ message: 'Dateiname ist erforderlich' }) + @MaxLength(255) + filename!: string; + + @ApiProperty({ example: 'application/pdf' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + mimetype!: string; + + @ApiProperty({ example: 524288, description: 'Dateigröße in Bytes' }) + @IsInt() + @Min(1, { message: 'Datei darf nicht leer sein' }) + @Max(10485760, { message: 'Datei darf maximal 10MB groß sein' }) + size!: number; + + @ApiProperty({ description: 'Base64-kodierte Dateidaten' }) + @IsString() + @IsNotEmpty() + @MaxLength(14000000, { message: 'Base64-Daten dürfen maximal ~10MB betragen' }) + data!: string; +} diff --git a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts new file mode 100644 index 0000000..9469fa8 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts @@ -0,0 +1,190 @@ +import { + Controller, + Get, + Patch, + Post, + Delete, + Param, + Body, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { ExpertProfileService } from './expert-profile.service'; +import { UpdateSkillsDto } from './dto/update-skills.dto'; +import { CreateExperienceDto } from './dto/create-experience.dto'; +import { CreateLanguageDto } from './dto/create-language.dto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { CreateCertificationDto } from './dto/create-certification.dto'; +import { UpdateCertificationDto } from './dto/update-certification.dto'; +import { UploadAttachmentDto } from './dto/upload-attachment.dto'; + +@ApiTags('Experten-Profil') +@ApiBearerAuth('access-token') +@Controller('expert-profile') +export class ExpertProfileController { + constructor(private readonly expertProfileService: ExpertProfileService) {} + + // ============================================================ + // Profil + // ============================================================ + @Get('me') + @ApiOperation({ summary: 'Eigenes Experten-Profil abrufen' }) + async getProfile(@CurrentUser('sub') userId: string) { + return this.expertProfileService.getOrCreateProfile(userId); + } + + // ============================================================ + // Skills + // ============================================================ + @Patch('me/skills') + @ApiOperation({ summary: 'Skills aktualisieren' }) + async updateSkills( + @CurrentUser('sub') userId: string, + @Body() dto: UpdateSkillsDto, + ) { + return this.expertProfileService.updateSkills(userId, dto); + } + + // ============================================================ + // Erfahrung + // ============================================================ + @Post('me/experiences') + @ApiOperation({ summary: 'Erfahrung hinzufügen' }) + async addExperience( + @CurrentUser('sub') userId: string, + @Body() dto: CreateExperienceDto, + ) { + return this.expertProfileService.addExperience(userId, dto); + } + + @Delete('me/experiences/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Erfahrung löschen' }) + async deleteExperience( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.expertProfileService.deleteExperience(userId, id); + } + + // ============================================================ + // Sprachen + // ============================================================ + @Post('me/languages') + @ApiOperation({ summary: 'Sprache hinzufügen' }) + async addLanguage( + @CurrentUser('sub') userId: string, + @Body() dto: CreateLanguageDto, + ) { + return this.expertProfileService.addLanguage(userId, dto); + } + + @Delete('me/languages/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Sprache löschen' }) + async deleteLanguage( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.expertProfileService.deleteLanguage(userId, id); + } + + // ============================================================ + // Projekte + // ============================================================ + @Post('me/projects') + @ApiOperation({ summary: 'Projekt hinzufügen' }) + async addProject( + @CurrentUser('sub') userId: string, + @Body() dto: CreateProjectDto, + ) { + return this.expertProfileService.addProject(userId, dto); + } + + @Patch('me/projects/:id') + @ApiOperation({ summary: 'Projekt bearbeiten' }) + async updateProject( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProjectDto, + ) { + return this.expertProfileService.updateProject(userId, id, dto); + } + + @Delete('me/projects/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Projekt löschen' }) + async deleteProject( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.expertProfileService.deleteProject(userId, id); + } + + // ============================================================ + // Zertifizierungen + // ============================================================ + @Post('me/certifications') + @ApiOperation({ summary: 'Zertifizierung hinzufügen' }) + async addCertification( + @CurrentUser('sub') userId: string, + @Body() dto: CreateCertificationDto, + ) { + return this.expertProfileService.addCertification(userId, dto); + } + + @Patch('me/certifications/:id') + @ApiOperation({ summary: 'Zertifizierung bearbeiten' }) + async updateCertification( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCertificationDto, + ) { + return this.expertProfileService.updateCertification(userId, id, dto); + } + + @Delete('me/certifications/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Zertifizierung löschen' }) + async deleteCertification( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.expertProfileService.deleteCertification(userId, id); + } + + // ============================================================ + // Profilanlagen + // ============================================================ + @Post('me/attachments') + @ApiOperation({ summary: 'Datei hochladen' }) + async uploadAttachment( + @CurrentUser('sub') userId: string, + @Body() dto: UploadAttachmentDto, + ) { + return this.expertProfileService.uploadAttachment(userId, dto); + } + + @Get('me/attachments/:id') + @ApiOperation({ summary: 'Datei herunterladen' }) + async downloadAttachment( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.expertProfileService.downloadAttachment(userId, id); + } + + @Delete('me/attachments/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Datei löschen' }) + async deleteAttachment( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.expertProfileService.deleteAttachment(userId, id); + } +} diff --git a/packages/core-service/src/core/expert-profile/expert-profile.module.ts b/packages/core-service/src/core/expert-profile/expert-profile.module.ts new file mode 100644 index 0000000..7d5ec38 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/expert-profile.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ExpertProfileController } from './expert-profile.controller'; +import { ExpertProfileService } from './expert-profile.service'; + +@Module({ + controllers: [ExpertProfileController], + providers: [ExpertProfileService], + exports: [ExpertProfileService], +}) +export class ExpertProfileModule {} diff --git a/packages/core-service/src/core/expert-profile/expert-profile.service.ts b/packages/core-service/src/core/expert-profile/expert-profile.service.ts new file mode 100644 index 0000000..9b2a9b4 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/expert-profile.service.ts @@ -0,0 +1,319 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { UpdateSkillsDto } from './dto/update-skills.dto'; +import { CreateExperienceDto } from './dto/create-experience.dto'; +import { CreateLanguageDto } from './dto/create-language.dto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { CreateCertificationDto } from './dto/create-certification.dto'; +import { UpdateCertificationDto } from './dto/update-certification.dto'; +import { UploadAttachmentDto } from './dto/upload-attachment.dto'; + +@Injectable() +export class ExpertProfileService { + private readonly logger = new Logger(ExpertProfileService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ============================================================ + // Profil laden / auto-erstellen + // ============================================================ + async getOrCreateProfile(userId: string) { + const profile = await this.prisma.expertProfile.upsert({ + where: { userId }, + create: { userId }, + update: {}, + include: { + experiences: { orderBy: { createdAt: 'desc' } }, + languages: { orderBy: { language: 'asc' } }, + projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] }, + certifications: { orderBy: { issueYear: 'desc' } }, + attachments: { + select: { id: true, filename: true, mimetype: true, size: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + return profile; + } + + /** + * Profil-ID für einen User ermitteln (erstellt bei Bedarf). + */ + private async ensureProfileId(userId: string): Promise { + const profile = await this.prisma.expertProfile.upsert({ + where: { userId }, + create: { userId }, + update: {}, + select: { id: true }, + }); + return profile.id; + } + + // ============================================================ + // Skills + // ============================================================ + async updateSkills(userId: string, dto: UpdateSkillsDto) { + const profileId = await this.ensureProfileId(userId); + + const updated = await this.prisma.expertProfile.update({ + where: { id: profileId }, + data: { skills: dto.skills }, + select: { skills: true }, + }); + + this.logger.log(`Skills aktualisiert für User ${userId}: ${dto.skills.length} Skills`); + return updated; + } + + // ============================================================ + // Erfahrung (Experience) + // ============================================================ + async addExperience(userId: string, dto: CreateExperienceDto) { + const profileId = await this.ensureProfileId(userId); + + const experience = await this.prisma.expertExperience.create({ + data: { + expertProfileId: profileId, + area: dto.area, + years: dto.years, + ...(dto.level !== undefined && { level: dto.level }), + }, + }); + + this.logger.log(`Erfahrung hinzugefügt: ${dto.area} (${dto.years} Jahre)`); + return experience; + } + + async deleteExperience(userId: string, experienceId: string) { + await this.verifyOwnership(userId, 'expertExperience', experienceId); + + await this.prisma.expertExperience.delete({ + where: { id: experienceId }, + }); + } + + // ============================================================ + // Sprachen (Languages) + // ============================================================ + async addLanguage(userId: string, dto: CreateLanguageDto) { + const profileId = await this.ensureProfileId(userId); + + const language = await this.prisma.expertLanguage.create({ + data: { + expertProfileId: profileId, + language: dto.language, + level: dto.level, + }, + }); + + this.logger.log(`Sprache hinzugefügt: ${dto.language} (${dto.level})`); + return language; + } + + async deleteLanguage(userId: string, languageId: string) { + await this.verifyOwnership(userId, 'expertLanguage', languageId); + + await this.prisma.expertLanguage.delete({ + where: { id: languageId }, + }); + } + + // ============================================================ + // Projekte (Projects) + // ============================================================ + async addProject(userId: string, dto: CreateProjectDto) { + const profileId = await this.ensureProfileId(userId); + + const project = await this.prisma.expertProject.create({ + data: { + expertProfileId: profileId, + fromMonth: dto.fromMonth, + fromYear: dto.fromYear, + toMonth: dto.isCurrent ? null : (dto.toMonth ?? null), + toYear: dto.isCurrent ? null : (dto.toYear ?? null), + isCurrent: dto.isCurrent ?? false, + role: dto.role, + ...(dto.tasks !== undefined && { tasks: dto.tasks }), + ...(dto.company !== undefined && { company: dto.company }), + ...(dto.companySize !== undefined && { companySize: dto.companySize }), + ...(dto.industry !== undefined && { industry: dto.industry }), + }, + }); + + this.logger.log(`Projekt hinzugefügt: ${dto.role} (${dto.fromMonth}/${dto.fromYear})`); + return project; + } + + async updateProject(userId: string, projectId: string, dto: UpdateProjectDto) { + await this.verifyOwnership(userId, 'expertProject', projectId); + + const project = await this.prisma.expertProject.update({ + where: { id: projectId }, + data: { + ...(dto.fromMonth !== undefined && { fromMonth: dto.fromMonth }), + ...(dto.fromYear !== undefined && { fromYear: dto.fromYear }), + ...(dto.isCurrent !== undefined && { + isCurrent: dto.isCurrent, + toMonth: dto.isCurrent ? null : (dto.toMonth ?? undefined), + toYear: dto.isCurrent ? null : (dto.toYear ?? undefined), + }), + ...(!dto.isCurrent && dto.toMonth !== undefined && { toMonth: dto.toMonth }), + ...(!dto.isCurrent && dto.toYear !== undefined && { toYear: dto.toYear }), + ...(dto.role !== undefined && { role: dto.role }), + ...(dto.tasks !== undefined && { tasks: dto.tasks }), + ...(dto.company !== undefined && { company: dto.company }), + ...(dto.companySize !== undefined && { companySize: dto.companySize }), + ...(dto.industry !== undefined && { industry: dto.industry }), + }, + }); + + return project; + } + + async deleteProject(userId: string, projectId: string) { + await this.verifyOwnership(userId, 'expertProject', projectId); + + await this.prisma.expertProject.delete({ + where: { id: projectId }, + }); + } + + // ============================================================ + // Zertifizierungen (Certifications) + // ============================================================ + async addCertification(userId: string, dto: CreateCertificationDto) { + const profileId = await this.ensureProfileId(userId); + + const certification = await this.prisma.expertCertification.create({ + data: { + expertProfileId: profileId, + title: dto.title, + issuingBody: dto.issuingBody, + ...(dto.website !== undefined && { website: dto.website }), + issueYear: dto.issueYear, + }, + }); + + this.logger.log(`Zertifizierung hinzugefügt: ${dto.title}`); + return certification; + } + + async updateCertification(userId: string, certificationId: string, dto: UpdateCertificationDto) { + await this.verifyOwnership(userId, 'expertCertification', certificationId); + + const certification = await this.prisma.expertCertification.update({ + where: { id: certificationId }, + data: { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.issuingBody !== undefined && { issuingBody: dto.issuingBody }), + ...(dto.website !== undefined && { website: dto.website }), + ...(dto.issueYear !== undefined && { issueYear: dto.issueYear }), + }, + }); + + return certification; + } + + async deleteCertification(userId: string, certificationId: string) { + await this.verifyOwnership(userId, 'expertCertification', certificationId); + + await this.prisma.expertCertification.delete({ + where: { id: certificationId }, + }); + } + + // ============================================================ + // Profilanlagen (Attachments) + // ============================================================ + async uploadAttachment(userId: string, dto: UploadAttachmentDto) { + const profileId = await this.ensureProfileId(userId); + + const attachment = await this.prisma.expertAttachment.create({ + data: { + expertProfileId: profileId, + filename: dto.filename, + mimetype: dto.mimetype, + size: dto.size, + data: dto.data, + }, + select: { id: true, filename: true, mimetype: true, size: true, createdAt: true }, + }); + + this.logger.log(`Anhang hochgeladen: ${dto.filename} (${dto.size} Bytes)`); + return attachment; + } + + async downloadAttachment(userId: string, attachmentId: string) { + const attachment = await this.prisma.expertAttachment.findUnique({ + where: { id: attachmentId }, + include: { expertProfile: { select: { userId: true } } }, + }); + + if (!attachment) { + throw new NotFoundException('Anhang nicht gefunden'); + } + + if (attachment.expertProfile.userId !== userId) { + throw new ForbiddenException('Kein Zugriff auf diesen Anhang'); + } + + return { + id: attachment.id, + filename: attachment.filename, + mimetype: attachment.mimetype, + size: attachment.size, + data: attachment.data, + }; + } + + async deleteAttachment(userId: string, attachmentId: string) { + const attachment = await this.prisma.expertAttachment.findUnique({ + where: { id: attachmentId }, + include: { expertProfile: { select: { userId: true } } }, + }); + + if (!attachment) { + throw new NotFoundException('Anhang nicht gefunden'); + } + + if (attachment.expertProfile.userId !== userId) { + throw new ForbiddenException('Kein Zugriff auf diesen Anhang'); + } + + await this.prisma.expertAttachment.delete({ + where: { id: attachmentId }, + }); + + this.logger.log(`Anhang gelöscht: ${attachment.filename}`); + } + + // ============================================================ + // Hilfsfunktion: Ownership-Check + // ============================================================ + private async verifyOwnership( + userId: string, + model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification', + entityId: string, + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entity = await (this.prisma[model] as any).findUnique({ + where: { id: entityId }, + include: { expertProfile: { select: { userId: true } } }, + }); + + if (!entity) { + throw new NotFoundException('Eintrag nicht gefunden'); + } + + if (entity.expertProfile.userId !== userId) { + throw new ForbiddenException('Kein Zugriff auf diesen Eintrag'); + } + } +} diff --git a/packages/core-service/src/main.ts b/packages/core-service/src/main.ts index 5857ca8..05aa80f 100644 --- a/packages/core-service/src/main.ts +++ b/packages/core-service/src/main.ts @@ -16,8 +16,8 @@ async function bootstrap(): Promise { app.use(helmet()); app.use(cookieParser()); - // Body size limit erhoehen fuer Base64 Avatar-Uploads (Standard ~100KB) - app.use(json({ limit: '1mb' })); + // Body size limit für Base64-Uploads (Avatare, Profilanlagen bis 10MB) + app.use(json({ limit: '12mb' })); // CORS const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [ diff --git a/packages/frontend/src/components/Modal.module.css b/packages/frontend/src/components/Modal.module.css new file mode 100644 index 0000000..5cb744c --- /dev/null +++ b/packages/frontend/src/components/Modal.module.css @@ -0,0 +1,86 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + padding: 1rem; + animation: fadeIn 0.15s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.container { + background: var(--color-bg-card, #fff); + border-radius: var(--radius-md, 8px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideUp 0.15s ease-out; +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); + flex-shrink: 0; +} + +.title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 1.5rem; + line-height: 1; + color: var(--color-text-muted, #9ca3af); + background: none; + border: none; + border-radius: var(--radius-sm, 4px); + cursor: pointer; + transition: all 0.15s; +} + +.closeButton:hover { + background: var(--color-bg, #f3f4f6); + color: var(--color-text, #111827); +} + +.body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +@media (max-width: 640px) { + .overlay { + padding: 0; + align-items: flex-end; + } + + .container { + max-height: 95vh; + border-radius: var(--radius-md, 8px) var(--radius-md, 8px) 0 0; + } +} diff --git a/packages/frontend/src/components/Modal.tsx b/packages/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..5cb31aa --- /dev/null +++ b/packages/frontend/src/components/Modal.tsx @@ -0,0 +1,60 @@ +import { useEffect, useCallback, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Modal.module.css'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + maxWidth?: string; +} + +export function Modal({ isOpen, onClose, title, children, maxWidth = '600px' }: ModalProps) { + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = ''; + }; + }, [isOpen, handleEscape]); + + if (!isOpen) return null; + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={title} + > +
+

{title}

+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/packages/frontend/src/profile/ExpertProfileTab.module.css b/packages/frontend/src/profile/ExpertProfileTab.module.css new file mode 100644 index 0000000..b582cb1 --- /dev/null +++ b/packages/frontend/src/profile/ExpertProfileTab.module.css @@ -0,0 +1,469 @@ +.expertContainer { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.loading { + text-align: center; + color: var(--color-text-muted); + padding: 3rem 0; + font-size: 0.9375rem; +} + +.errorBox { + background: #fef2f2; + color: var(--color-error); + padding: 1rem; + border-radius: var(--radius-md); + border: 1px solid #fecaca; + font-size: 0.875rem; +} + +/* === Section Card === */ +.section { + background: var(--color-bg-card); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.sectionTitle { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +/* === Chips/Tags === */ +.chipContainer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: var(--color-primary-light, #eff6ff); + color: var(--color-primary); + border-radius: 9999px; + font-size: 0.8125rem; + font-weight: 500; +} + +.chipRemove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 1rem; + line-height: 1; + color: var(--color-primary); + background: none; + border: none; + border-radius: 50%; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s; +} + +.chipRemove:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.1); +} + +.chipInput { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.chipInput input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + flex: 1; + max-width: 250px; +} + +.chipInput input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +/* === Entry List === */ +.entryList { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.entryItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 0.75rem; + background: var(--color-bg, #f9fafb); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + font-size: 0.875rem; +} + +.entryInfo { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.entryPrimary { + font-weight: 500; +} + +.entrySecondary { + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.entryBadge { + display: inline-block; + padding: 0.125rem 0.5rem; + background: var(--color-primary-light, #eff6ff); + color: var(--color-primary); + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.entryActions { + display: flex; + gap: 0.375rem; + flex-shrink: 0; + margin-left: 0.75rem; +} + +/* === Action Buttons (small) === */ +.btnIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + color: var(--color-text-secondary); + transition: all 0.15s; +} + +.btnIcon:hover { + background: var(--color-bg); + color: var(--color-text); +} + +.btnIconDanger:hover { + background: #fef2f2; + color: var(--color-error); + border-color: #fecaca; +} + +/* === Add Form (inline) === */ +.addForm { + display: flex; + gap: 0.5rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.addForm .fieldInline { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.addForm .fieldInline label { + font-size: 0.75rem; + color: var(--color-text-muted); + font-weight: 500; +} + +.addForm input, +.addForm select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; +} + +.addForm input:focus, +.addForm select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +/* === Buttons === */ +.btnPrimary { + padding: 0.5rem 1rem; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.btnPrimary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btnPrimary:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.btnSecondary { + padding: 0.5rem 1rem; + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btnSecondary:hover { + background: var(--color-bg); +} + +.btnDanger { + padding: 0.5rem 1rem; + background: var(--color-error); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.btnDanger:hover:not(:disabled) { + background: #b91c1c; +} + +.btnRow { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 0.5rem; +} + +/* === Modal Form === */ +.modalForm { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modalField { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.modalField label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); +} + +.modalField input, +.modalField select, +.modalField textarea { + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9375rem; + transition: border-color 0.15s; + font-family: inherit; +} + +.modalField input:focus, +.modalField select:focus, +.modalField textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.modalField input:disabled, +.modalField select:disabled { + background: var(--color-bg); + color: var(--color-text-muted); +} + +.modalField small { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.modalFieldRow { + display: flex; + gap: 1rem; +} + +.modalFieldRow .modalField { + flex: 1; +} + +.charCount { + text-align: right; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.charCountWarn { + color: var(--color-error); +} + +.checkboxRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.checkboxRow input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +.checkboxRow label { + font-size: 0.875rem; + color: var(--color-text); + cursor: pointer; +} + +/* === Messages === */ +.success { + background: #f0fdf4; + color: var(--color-success); + padding: 0.625rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + border: 1px solid #bbf7d0; + margin-bottom: 0.75rem; +} + +.error { + background: #fef2f2; + color: var(--color-error); + padding: 0.625rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + border: 1px solid #fecaca; + margin-bottom: 0.75rem; +} + +/* === Empty State === */ +.emptyState { + text-align: center; + color: var(--color-text-muted); + padding: 1.5rem 0; + font-size: 0.875rem; +} + +/* === Attachment List === */ +.attachmentItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 0.75rem; + background: var(--color-bg, #f9fafb); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + font-size: 0.875rem; +} + +.attachmentInfo { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + flex: 1; +} + +.attachmentName { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentMeta { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.hiddenInput { + display: none; +} + +/* === Responsive === */ +@media (max-width: 640px) { + .addForm { + flex-direction: column; + align-items: stretch; + } + + .chipInput { + flex-direction: column; + align-items: stretch; + } + + .chipInput input { + max-width: 100%; + } + + .entryItem { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .entryActions { + margin-left: 0; + } + + .modalFieldRow { + flex-direction: column; + } +} diff --git a/packages/frontend/src/profile/ExpertProfileTab.tsx b/packages/frontend/src/profile/ExpertProfileTab.tsx new file mode 100644 index 0000000..2b7df7b --- /dev/null +++ b/packages/frontend/src/profile/ExpertProfileTab.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '../api/client'; +import { SkillsSection } from './sections/SkillsSection'; +import { ExperienceSection } from './sections/ExperienceSection'; +import { LanguagesSection } from './sections/LanguagesSection'; +import { ProjectsSection } from './sections/ProjectsSection'; +import { CertificationsSection } from './sections/CertificationsSection'; +import { AttachmentsSection } from './sections/AttachmentsSection'; +import styles from './ExpertProfileTab.module.css'; + +interface ExpertExperience { + id: string; + area: string; + years: number; + level?: string | null; +} + +interface ExpertLanguage { + id: string; + language: string; + level: string; +} + +interface ExpertProject { + id: string; + fromMonth: number; + fromYear: number; + toMonth?: number | null; + toYear?: number | null; + isCurrent: boolean; + role: string; + tasks?: string | null; + company?: string | null; + companySize?: string | null; + industry?: string | null; +} + +interface ExpertCertification { + id: string; + title: string; + issuingBody: string; + website?: string | null; + issueYear: number; +} + +interface AttachmentMeta { + id: string; + filename: string; + mimetype: string; + size: number; + createdAt: string; +} + +interface ExpertProfile { + id: string; + skills: string[]; + experiences: ExpertExperience[]; + languages: ExpertLanguage[]; + projects: ExpertProject[]; + certifications: ExpertCertification[]; + attachments: AttachmentMeta[]; +} + +export type { ExpertExperience, ExpertLanguage, ExpertProject, ExpertCertification, AttachmentMeta }; + +export function ExpertProfileTab() { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const loadProfile = useCallback(async () => { + try { + const { data } = await api.get('/expert-profile/me'); + setProfile(data); + setError(''); + } catch { + setError('Experten-Profil konnte nicht geladen werden'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadProfile(); + }, [loadProfile]); + + if (loading) { + return ( +
+ Experten-Profil wird geladen... +
+ ); + } + + if (error && !profile) { + return
{error}
; + } + + if (!profile) return null; + + return ( +
+ + + + + + +
+ ); +} diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx index 5c6d54c..7817743 100644 --- a/packages/frontend/src/profile/ProfilePage.tsx +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -3,6 +3,7 @@ import { useAuth } from '../auth/AuthContext'; import api from '../api/client'; import { UserAvatar } from '../components/UserAvatar'; import { resizeImageToBase64 } from '../utils/imageUtils'; +import { ExpertProfileTab } from './ExpertProfileTab'; import styles from './ProfilePage.module.css'; type ProfileTab = 'personal' | 'expert' | 'password'; @@ -532,15 +533,8 @@ export function ProfilePage() { )} - {/* === Tab: Experten Profil (Platzhalter) === */} - {activeTab === 'expert' && ( -
-

Experten Profil

-

- Hier können Sie zukünftig Ihr Experten-Profil verwalten. -

-
- )} + {/* === Tab: Experten Profil === */} + {activeTab === 'expert' && } {/* === Tab: Passwort ändern + 2FA === */} {activeTab === 'password' && ( diff --git a/packages/frontend/src/profile/sections/AttachmentsSection.tsx b/packages/frontend/src/profile/sections/AttachmentsSection.tsx new file mode 100644 index 0000000..2a95fb9 --- /dev/null +++ b/packages/frontend/src/profile/sections/AttachmentsSection.tsx @@ -0,0 +1,165 @@ +import { useState, useRef, type ChangeEvent } from 'react'; +import api from '../../api/client'; +import type { AttachmentMeta } from '../ExpertProfileTab'; +import styles from '../ExpertProfileTab.module.css'; + +interface AttachmentsSectionProps { + attachments: AttachmentMeta[]; + onUpdate: () => Promise; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }); +} + +const ACCEPTED_TYPES = '.pdf,.jpg,.jpeg,.png,.docx,.doc'; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +export function AttachmentsSection({ attachments, onUpdate }: AttachmentsSectionProps) { + const fileInputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const handleFileSelect = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_FILE_SIZE) { + setError('Datei darf maximal 10MB groß sein'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + await api.post('/expert-profile/me/attachments', { + filename: file.name, + mimetype: file.type || 'application/octet-stream', + size: file.size, + data: base64, + }); + + setSuccess(`"${file.name}" erfolgreich hochgeladen`); + await onUpdate(); + } catch { + setError('Fehler beim Hochladen'); + } finally { + setLoading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleDownload = async (id: string, filename: string) => { + try { + const { data } = await api.get<{ data: string; mimetype: string }>( + `/expert-profile/me/attachments/${id}`, + ); + + const link = document.createElement('a'); + link.href = data.data; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch { + setError('Fehler beim Herunterladen'); + } + }; + + const handleDelete = async (id: string) => { + setLoading(true); + setError(''); + setSuccess(''); + try { + await api.delete(`/expert-profile/me/attachments/${id}`); + await onUpdate(); + } catch { + setError('Fehler beim Löschen'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Profilanlagen

+ +
+ + + + {error &&
{error}
} + {success &&
{success}
} + + {attachments.length > 0 ? ( +
+ {attachments.map((att) => ( +
+
+ {att.filename} + + {formatFileSize(att.size)} · {formatDate(att.createdAt)} + +
+
+ + +
+
+ ))} +
+ ) : ( +

+ Noch keine Anlagen hochgeladen. Unterstützte Formate: PDF, JPEG, PNG, DOCX (max. 10MB) +

+ )} +
+ ); +} diff --git a/packages/frontend/src/profile/sections/CertificationModal.tsx b/packages/frontend/src/profile/sections/CertificationModal.tsx new file mode 100644 index 0000000..a642c99 --- /dev/null +++ b/packages/frontend/src/profile/sections/CertificationModal.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { Modal } from '../../components/Modal'; +import api from '../../api/client'; +import type { ExpertCertification } from '../ExpertProfileTab'; +import styles from '../ExpertProfileTab.module.css'; + +interface CertificationModalProps { + isOpen: boolean; + onClose: () => void; + onSave: () => Promise; + certification: ExpertCertification | null; +} + +const currentYear = new Date().getFullYear(); +const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); + +export function CertificationModal({ isOpen, onClose, onSave, certification }: CertificationModalProps) { + const [title, setTitle] = useState(''); + const [issuingBody, setIssuingBody] = useState(''); + const [website, setWebsite] = useState(''); + const [issueYear, setIssueYear] = useState(currentYear); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (isOpen) { + if (certification) { + setTitle(certification.title); + setIssuingBody(certification.issuingBody); + setWebsite(certification.website ?? ''); + setIssueYear(certification.issueYear); + } else { + setTitle(''); + setIssuingBody(''); + setWebsite(''); + setIssueYear(currentYear); + } + setError(''); + } + }, [isOpen, certification]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const payload = { + title: title.trim(), + issuingBody: issuingBody.trim(), + ...(website.trim() && { website: website.trim() }), + issueYear, + }; + + try { + if (certification) { + await api.patch(`/expert-profile/me/certifications/${certification.id}`, payload); + } else { + await api.post('/expert-profile/me/certifications', payload); + } + await onSave(); + } catch (err: unknown) { + const apiErr = err as { response?: { data?: { message?: string } } }; + setError(apiErr.response?.data?.message ?? 'Fehler beim Speichern'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ {error &&
{error}
} + +
+ + setTitle(e.target.value)} + placeholder="Welche Bezeichnung trägt Ihr Zertifikat?" + maxLength={300} + required + /> +
+ +
+ + setIssuingBody(e.target.value)} + placeholder="Welche Organisation hat Ihr Zertifikat erstellt?" + maxLength={300} + required + /> +
+ +
+ + setWebsite(e.target.value)} + placeholder="https://" + maxLength={500} + /> +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/packages/frontend/src/profile/sections/CertificationsSection.tsx b/packages/frontend/src/profile/sections/CertificationsSection.tsx new file mode 100644 index 0000000..5344d97 --- /dev/null +++ b/packages/frontend/src/profile/sections/CertificationsSection.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import type { ExpertCertification } from '../ExpertProfileTab'; +import { CertificationModal } from './CertificationModal'; +import styles from '../ExpertProfileTab.module.css'; +import api from '../../api/client'; + +interface CertificationsSectionProps { + certifications: ExpertCertification[]; + onUpdate: () => Promise; +} + +export function CertificationsSection({ certifications, onUpdate }: CertificationsSectionProps) { + const [modalOpen, setModalOpen] = useState(false); + const [editCert, setEditCert] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleEdit = (cert: ExpertCertification) => { + setEditCert(cert); + setModalOpen(true); + }; + + const handleAdd = () => { + setEditCert(null); + setModalOpen(true); + }; + + const handleDelete = async (id: string) => { + setLoading(true); + setError(''); + try { + await api.delete(`/expert-profile/me/certifications/${id}`); + await onUpdate(); + } catch { + setError('Fehler beim Löschen'); + } finally { + setLoading(false); + } + }; + + const handleModalClose = () => { + setModalOpen(false); + setEditCert(null); + }; + + const handleModalSave = async () => { + handleModalClose(); + await onUpdate(); + }; + + return ( +
+
+

Zertifizierungen

+ +
+ + {error &&
{error}
} + + {certifications.length > 0 ? ( +
+ {certifications.map((cert) => ( +
+
+ {cert.title} + {cert.issuingBody} + {cert.issueYear} +
+
+ + +
+
+ ))} +
+ ) : ( +

Noch keine Zertifizierungen hinzugefügt

+ )} + + +
+ ); +} diff --git a/packages/frontend/src/profile/sections/ExperienceSection.tsx b/packages/frontend/src/profile/sections/ExperienceSection.tsx new file mode 100644 index 0000000..a61b6bf --- /dev/null +++ b/packages/frontend/src/profile/sections/ExperienceSection.tsx @@ -0,0 +1,129 @@ +import { useState, type FormEvent } from 'react'; +import api from '../../api/client'; +import type { ExpertExperience } from '../ExpertProfileTab'; +import styles from '../ExpertProfileTab.module.css'; + +interface ExperienceSectionProps { + experiences: ExpertExperience[]; + onUpdate: () => Promise; +} + +export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionProps) { + const [area, setArea] = useState(''); + const [years, setYears] = useState(''); + const [level, setLevel] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleAdd = async (e: FormEvent) => { + e.preventDefault(); + if (!area.trim() || !years) return; + + setLoading(true); + setError(''); + try { + await api.post('/expert-profile/me/experiences', { + area: area.trim(), + years: parseInt(years, 10), + ...(level && { level }), + }); + setArea(''); + setYears(''); + setLevel(''); + await onUpdate(); + } catch { + setError('Fehler beim Hinzufügen'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + setLoading(true); + setError(''); + try { + await api.delete(`/expert-profile/me/experiences/${id}`); + await onUpdate(); + } catch { + setError('Fehler beim Löschen'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Erfahrung

+
+ + {error &&
{error}
} + + {experiences.length > 0 ? ( +
+ {experiences.map((exp) => ( +
+
+ {exp.area} + {exp.years} Jahre + {exp.level && {exp.level}} +
+
+ +
+
+ ))} +
+ ) : ( +

Noch keine Erfahrung hinzugefügt

+ )} + +
+
+ + setArea(e.target.value)} + placeholder="z.B. IT Infrastruktur" + maxLength={200} + required + /> +
+
+ + setYears(e.target.value)} + placeholder="0" + min={0} + max={60} + style={{ width: '80px' }} + required + /> +
+
+ + +
+ +
+
+ ); +} diff --git a/packages/frontend/src/profile/sections/LanguagesSection.tsx b/packages/frontend/src/profile/sections/LanguagesSection.tsx new file mode 100644 index 0000000..7cc9f96 --- /dev/null +++ b/packages/frontend/src/profile/sections/LanguagesSection.tsx @@ -0,0 +1,114 @@ +import { useState, type FormEvent } from 'react'; +import api from '../../api/client'; +import type { ExpertLanguage } from '../ExpertProfileTab'; +import styles from '../ExpertProfileTab.module.css'; + +interface LanguagesSectionProps { + languages: ExpertLanguage[]; + onUpdate: () => Promise; +} + +const LANGUAGE_LEVELS = ['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1']; + +export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) { + const [language, setLanguage] = useState(''); + const [level, setLevel] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleAdd = async (e: FormEvent) => { + e.preventDefault(); + if (!language.trim() || !level) return; + + setLoading(true); + setError(''); + try { + await api.post('/expert-profile/me/languages', { + language: language.trim(), + level, + }); + setLanguage(''); + setLevel(''); + await onUpdate(); + } catch { + setError('Fehler beim Hinzufügen'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + setLoading(true); + setError(''); + try { + await api.delete(`/expert-profile/me/languages/${id}`); + await onUpdate(); + } catch { + setError('Fehler beim Löschen'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Sprachen

+
+ + {error &&
{error}
} + + {languages.length > 0 ? ( +
+ {languages.map((lang) => ( +
+
+ {lang.language} + {lang.level} +
+
+ +
+
+ ))} +
+ ) : ( +

Noch keine Sprachen hinzugefügt

+ )} + +
+
+ + setLanguage(e.target.value)} + placeholder="z.B. Deutsch" + maxLength={100} + required + /> +
+
+ + +
+ +
+
+ ); +} diff --git a/packages/frontend/src/profile/sections/ProjectModal.tsx b/packages/frontend/src/profile/sections/ProjectModal.tsx new file mode 100644 index 0000000..1746ab9 --- /dev/null +++ b/packages/frontend/src/profile/sections/ProjectModal.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { Modal } from '../../components/Modal'; +import api from '../../api/client'; +import type { ExpertProject } from '../ExpertProfileTab'; +import styles from '../ExpertProfileTab.module.css'; + +interface ProjectModalProps { + isOpen: boolean; + onClose: () => void; + onSave: () => Promise; + project: ExpertProject | null; +} + +const MONTHS = [ + { value: 1, label: 'Januar' }, { value: 2, label: 'Februar' }, + { value: 3, label: 'März' }, { value: 4, label: 'April' }, + { value: 5, label: 'Mai' }, { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, { value: 8, label: 'August' }, + { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, { value: 12, label: 'Dezember' }, +]; + +const COMPANY_SIZES = ['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+']; + +const INDUSTRIES = [ + 'IT-Dienstleistung', 'Software-Entwicklung', 'Cloud & Hosting', 'Telekommunikation', + 'Finanzdienstleistung', 'Versicherung', 'Gesundheitswesen', 'Pharma & Medizintechnik', + 'Automobilindustrie', 'Maschinenbau', 'Energiewirtschaft', 'Logistik & Transport', + 'Handel & E-Commerce', 'Medien & Unterhaltung', 'Bildung & Forschung', + 'Öffentlicher Sektor', 'Beratung & Consulting', 'Luft- und Raumfahrt', + 'Chemie & Werkstoffe', 'Sonstige', +]; + +const currentYear = new Date().getFullYear(); +const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); + +export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalProps) { + const [fromMonth, setFromMonth] = useState(1); + const [fromYear, setFromYear] = useState(currentYear); + const [toMonth, setToMonth] = useState(1); + const [toYear, setToYear] = useState(currentYear); + const [isCurrent, setIsCurrent] = useState(false); + const [role, setRole] = useState(''); + const [tasks, setTasks] = useState(''); + const [company, setCompany] = useState(''); + const [companySize, setCompanySize] = useState(''); + const [industry, setIndustry] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (isOpen) { + if (project) { + setFromMonth(project.fromMonth); + setFromYear(project.fromYear); + setToMonth(project.toMonth ?? 1); + setToYear(project.toYear ?? currentYear); + setIsCurrent(project.isCurrent); + setRole(project.role); + setTasks(project.tasks ?? ''); + setCompany(project.company ?? ''); + setCompanySize(project.companySize ?? ''); + setIndustry(project.industry ?? ''); + } else { + setFromMonth(1); + setFromYear(currentYear); + setToMonth(1); + setToYear(currentYear); + setIsCurrent(false); + setRole(''); + setTasks(''); + setCompany(''); + setCompanySize(''); + setIndustry(''); + } + setError(''); + } + }, [isOpen, project]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const payload = { + fromMonth, + fromYear, + ...(!isCurrent && { toMonth, toYear }), + isCurrent, + role: role.trim(), + ...(tasks.trim() && { tasks: tasks.trim() }), + ...(company.trim() && { company: company.trim() }), + ...(companySize && { companySize }), + ...(industry && { industry }), + }; + + try { + if (project) { + await api.patch(`/expert-profile/me/projects/${project.id}`, payload); + } else { + await api.post('/expert-profile/me/projects', payload); + } + await onSave(); + } catch (err: unknown) { + const apiErr = err as { response?: { data?: { message?: string } } }; + setError(apiErr.response?.data?.message ?? 'Fehler beim Speichern'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ {error &&
{error}
} + + {/* Zeitraum von */} +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ setIsCurrent(e.target.checked)} + /> + +
+ +
+ + setRole(e.target.value)} + placeholder="z.B. Senior DevOps Engineer" + maxLength={200} + required + /> +
+ +
+ +