feat: implement expert profile with skills, experience, languages, projects, certifications and attachments

Full-stack implementation of the Expert Profile tab with 6 sections:
- Skills (tag/chip UI with add/remove)
- Experience (area, years, optional level)
- Languages (language + proficiency level)
- Project History (modal form with dates, role, tasks, company details)
- Certifications (modal form with title, issuer, website, year)
- Attachments (file upload/download as Base64, max 10MB)

Backend: 15 API endpoints, 8 DTOs, full CRUD service with ownership verification.
Frontend: Reusable Modal component (React Portal), ExpertProfileTab orchestrator, 8 section components.
Database: 6 new tables (expert_profiles, expert_experiences, expert_languages, expert_projects, expert_certifications, expert_attachments).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 10:23:47 +01:00
parent 5d3958cd74
commit b326081c54
28 changed files with 2913 additions and 11 deletions

View file

@ -53,6 +53,7 @@ model User {
authProvider AuthProvider[] authProvider AuthProvider[]
tenantMemberships TenantMembership[] tenantMemberships TenantMembership[]
auditLogs AuditLog[] auditLogs AuditLog[]
expertProfile ExpertProfile?
@@map("users") @@map("users")
} }
@ -190,3 +191,137 @@ model AuditLog {
@@index([createdAt]) @@index([createdAt])
@@map("audit_logs") @@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")
}

View file

@ -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;

View file

@ -9,6 +9,7 @@ import { RedisModule } from './redis/redis.module';
import { AuthModule } from './core/auth/auth.module'; import { AuthModule } from './core/auth/auth.module';
import { UsersModule } from './core/users/users.module'; import { UsersModule } from './core/users/users.module';
import { TenantsModule } from './core/tenants/tenants.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation'; import { validateConfig } from './config/env.validation';
@ -40,6 +41,7 @@ import { validateConfig } from './config/env.validation';
AuthModule, AuthModule,
UsersModule, UsersModule,
TenantsModule, TenantsModule,
ExpertProfileModule,
], ],
providers: [ providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt // Global Guards: Alle Routen sind standardmaessig geschuetzt

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<string> {
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<void> {
// 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');
}
}
}

View file

@ -16,8 +16,8 @@ async function bootstrap(): Promise<void> {
app.use(helmet()); app.use(helmet());
app.use(cookieParser()); app.use(cookieParser());
// Body size limit erhoehen fuer Base64 Avatar-Uploads (Standard ~100KB) // Body size limit für Base64-Uploads (Avatare, Profilanlagen bis 10MB)
app.use(json({ limit: '1mb' })); app.use(json({ limit: '12mb' }));
// CORS // CORS
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [ const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [

View file

@ -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;
}
}

View file

@ -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(
<div className={styles.overlay} onClick={onClose}>
<div
className={styles.container}
style={{ maxWidth }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div>
<div className={styles.body}>{children}</div>
</div>
</div>,
document.body,
);
}

View file

@ -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;
}
}

View file

@ -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<ExpertProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loadProfile = useCallback(async () => {
try {
const { data } = await api.get<ExpertProfile>('/expert-profile/me');
setProfile(data);
setError('');
} catch {
setError('Experten-Profil konnte nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProfile();
}, [loadProfile]);
if (loading) {
return (
<div className={styles.loading}>
Experten-Profil wird geladen...
</div>
);
}
if (error && !profile) {
return <div className={styles.errorBox}>{error}</div>;
}
if (!profile) return null;
return (
<div className={styles.expertContainer}>
<SkillsSection skills={profile.skills} onUpdate={loadProfile} />
<ExperienceSection experiences={profile.experiences} onUpdate={loadProfile} />
<LanguagesSection languages={profile.languages} onUpdate={loadProfile} />
<ProjectsSection projects={profile.projects} onUpdate={loadProfile} />
<CertificationsSection certifications={profile.certifications} onUpdate={loadProfile} />
<AttachmentsSection attachments={profile.attachments} onUpdate={loadProfile} />
</div>
);
}

View file

@ -3,6 +3,7 @@ import { useAuth } from '../auth/AuthContext';
import api from '../api/client'; import api from '../api/client';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
import { resizeImageToBase64 } from '../utils/imageUtils'; import { resizeImageToBase64 } from '../utils/imageUtils';
import { ExpertProfileTab } from './ExpertProfileTab';
import styles from './ProfilePage.module.css'; import styles from './ProfilePage.module.css';
type ProfileTab = 'personal' | 'expert' | 'password'; type ProfileTab = 'personal' | 'expert' | 'password';
@ -532,15 +533,8 @@ export function ProfilePage() {
</div> </div>
)} )}
{/* === Tab: Experten Profil (Platzhalter) === */} {/* === Tab: Experten Profil === */}
{activeTab === 'expert' && ( {activeTab === 'expert' && <ExpertProfileTab />}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Experten Profil</h2>
<p className={styles.placeholder}>
Hier können Sie zukünftig Ihr Experten-Profil verwalten.
</p>
</div>
)}
{/* === Tab: Passwort ändern + 2FA === */} {/* === Tab: Passwort ändern + 2FA === */}
{activeTab === 'password' && ( {activeTab === 'password' && (

View file

@ -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<void>;
}
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<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
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<string>((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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Profilanlagen</h3>
<button
type="button"
className={styles.btnPrimary}
onClick={() => fileInputRef.current?.click()}
disabled={loading}
>
{loading ? 'Laden...' : '+ Datei hochladen'}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileSelect}
className={styles.hiddenInput}
/>
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}
{attachments.length > 0 ? (
<div className={styles.entryList}>
{attachments.map((att) => (
<div key={att.id} className={styles.attachmentItem}>
<div className={styles.attachmentInfo}>
<span className={styles.attachmentName}>{att.filename}</span>
<span className={styles.attachmentMeta}>
{formatFileSize(att.size)} · {formatDate(att.createdAt)}
</span>
</div>
<div className={styles.entryActions}>
<button
type="button"
className={styles.btnIcon}
onClick={() => handleDownload(att.id, att.filename)}
title="Herunterladen"
>
</button>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(att.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
) : (
<p className={styles.emptyState}>
Noch keine Anlagen hochgeladen. Unterstützte Formate: PDF, JPEG, PNG, DOCX (max. 10MB)
</p>
)}
</div>
);
}

View file

@ -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<void>;
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={certification ? 'Zertifizierung bearbeiten' : 'Zertifizierung hinzufügen'}
maxWidth="500px"
>
<form onSubmit={handleSubmit} className={styles.modalForm}>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.modalField}>
<label>Titel *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Welche Bezeichnung trägt Ihr Zertifikat?"
maxLength={300}
required
/>
</div>
<div className={styles.modalField}>
<label>Zertifizierungsstelle *</label>
<input
type="text"
value={issuingBody}
onChange={(e) => setIssuingBody(e.target.value)}
placeholder="Welche Organisation hat Ihr Zertifikat erstellt?"
maxLength={300}
required
/>
</div>
<div className={styles.modalField}>
<label>Website der Zertifizierungsstelle</label>
<input
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://"
maxLength={500}
/>
</div>
<div className={styles.modalField}>
<label>Ausstellungsjahr *</label>
<select value={issueYear} onChange={(e) => setIssueYear(Number(e.target.value))} required>
{YEARS.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className={styles.btnRow}>
<button type="button" className={styles.btnSecondary} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={styles.btnPrimary} disabled={loading || !title.trim() || !issuingBody.trim()}>
{loading ? 'Speichern...' : certification ? 'Speichern' : 'Hinzufügen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -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<void>;
}
export function CertificationsSection({ certifications, onUpdate }: CertificationsSectionProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editCert, setEditCert] = useState<ExpertCertification | null>(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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Zertifizierungen</h3>
<button type="button" className={styles.btnPrimary} onClick={handleAdd}>
+ Zertifizierung hinzufügen
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
{certifications.length > 0 ? (
<div className={styles.entryList}>
{certifications.map((cert) => (
<div key={cert.id} className={styles.entryItem}>
<div className={styles.entryInfo}>
<span className={styles.entryPrimary}>{cert.title}</span>
<span className={styles.entrySecondary}>{cert.issuingBody}</span>
<span className={styles.entryBadge}>{cert.issueYear}</span>
</div>
<div className={styles.entryActions}>
<button
type="button"
className={styles.btnIcon}
onClick={() => handleEdit(cert)}
disabled={loading}
title="Bearbeiten"
>
</button>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(cert.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
) : (
<p className={styles.emptyState}>Noch keine Zertifizierungen hinzugefügt</p>
)}
<CertificationModal
isOpen={modalOpen}
onClose={handleModalClose}
onSave={handleModalSave}
certification={editCert}
/>
</div>
);
}

View file

@ -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<void>;
}
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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Erfahrung</h3>
</div>
{error && <div className={styles.error}>{error}</div>}
{experiences.length > 0 ? (
<div className={styles.entryList}>
{experiences.map((exp) => (
<div key={exp.id} className={styles.entryItem}>
<div className={styles.entryInfo}>
<span className={styles.entryPrimary}>{exp.area}</span>
<span className={styles.entrySecondary}>{exp.years} Jahre</span>
{exp.level && <span className={styles.entryBadge}>{exp.level}</span>}
</div>
<div className={styles.entryActions}>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(exp.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
) : (
<p className={styles.emptyState}>Noch keine Erfahrung hinzugefügt</p>
)}
<form onSubmit={handleAdd} className={styles.addForm}>
<div className={styles.fieldInline}>
<label>Bereich</label>
<input
type="text"
value={area}
onChange={(e) => setArea(e.target.value)}
placeholder="z.B. IT Infrastruktur"
maxLength={200}
required
/>
</div>
<div className={styles.fieldInline}>
<label>Jahre</label>
<input
type="number"
value={years}
onChange={(e) => setYears(e.target.value)}
placeholder="0"
min={0}
max={60}
style={{ width: '80px' }}
required
/>
</div>
<div className={styles.fieldInline}>
<label>Level</label>
<select value={level} onChange={(e) => setLevel(e.target.value)}>
<option value=""> Optional </option>
<option value="Experte">Experte</option>
<option value="Fortgeschritten">Fortgeschritten</option>
<option value="Grundkenntnisse">Grundkenntnisse</option>
</select>
</div>
<button type="submit" className={styles.btnPrimary} disabled={loading || !area.trim() || !years}>
Hinzufügen
</button>
</form>
</div>
);
}

View file

@ -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<void>;
}
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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Sprachen</h3>
</div>
{error && <div className={styles.error}>{error}</div>}
{languages.length > 0 ? (
<div className={styles.entryList}>
{languages.map((lang) => (
<div key={lang.id} className={styles.entryItem}>
<div className={styles.entryInfo}>
<span className={styles.entryPrimary}>{lang.language}</span>
<span className={styles.entryBadge}>{lang.level}</span>
</div>
<div className={styles.entryActions}>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(lang.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
) : (
<p className={styles.emptyState}>Noch keine Sprachen hinzugefügt</p>
)}
<form onSubmit={handleAdd} className={styles.addForm}>
<div className={styles.fieldInline}>
<label>Sprache</label>
<input
type="text"
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="z.B. Deutsch"
maxLength={100}
required
/>
</div>
<div className={styles.fieldInline}>
<label>Niveau</label>
<select value={level} onChange={(e) => setLevel(e.target.value)} required>
<option value="">Bitte wählen</option>
{LANGUAGE_LEVELS.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</div>
<button type="submit" className={styles.btnPrimary} disabled={loading || !language.trim() || !level}>
Hinzufügen
</button>
</form>
</div>
);
}

View file

@ -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<void>;
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={project ? 'Projekt bearbeiten' : 'Neues Projekt hinzufügen'}
maxWidth="650px"
>
<form onSubmit={handleSubmit} className={styles.modalForm}>
{error && <div className={styles.error}>{error}</div>}
{/* Zeitraum von */}
<div className={styles.modalFieldRow}>
<div className={styles.modalField}>
<label>Zeitraum von *</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<select value={fromMonth} onChange={(e) => setFromMonth(Number(e.target.value))} required>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select value={fromYear} onChange={(e) => setFromYear(Number(e.target.value))} required>
{YEARS.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
<div className={styles.modalField}>
<label>Zeitraum bis *</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<select
value={toMonth}
onChange={(e) => setToMonth(Number(e.target.value))}
disabled={isCurrent}
>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
value={toYear}
onChange={(e) => setToYear(Number(e.target.value))}
disabled={isCurrent}
>
{YEARS.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</div>
<div className={styles.checkboxRow}>
<input
type="checkbox"
id="isCurrent"
checked={isCurrent}
onChange={(e) => setIsCurrent(e.target.checked)}
/>
<label htmlFor="isCurrent">bis heute</label>
</div>
<div className={styles.modalField}>
<label>Tätigkeit *</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="z.B. Senior DevOps Engineer"
maxLength={200}
required
/>
</div>
<div className={styles.modalField}>
<label>Aufgaben</label>
<textarea
value={tasks}
onChange={(e) => setTasks(e.target.value)}
placeholder="Beschreiben Sie Ihre Aufgaben..."
maxLength={1500}
rows={4}
/>
<div className={`${styles.charCount} ${tasks.length > 1400 ? styles.charCountWarn : ''}`}>
{tasks.length}/1.500
</div>
</div>
<div className={styles.modalField}>
<label>Firma</label>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Firmenname"
maxLength={200}
/>
</div>
<div className={styles.modalFieldRow}>
<div className={styles.modalField}>
<label>Unternehmensgröße</label>
<select value={companySize} onChange={(e) => setCompanySize(e.target.value)}>
<option value="">Anzahl der Mitarbeitenden</option>
{COMPANY_SIZES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className={styles.modalField}>
<label>Branche</label>
<select value={industry} onChange={(e) => setIndustry(e.target.value)}>
<option value="">Bitte wählen</option>
{INDUSTRIES.map((ind) => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
</div>
</div>
<div className={styles.btnRow}>
<button type="button" className={styles.btnSecondary} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={styles.btnPrimary} disabled={loading || !role.trim()}>
{loading ? 'Speichern...' : project ? 'Projekt speichern' : 'Projekt hinzufügen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,117 @@
import { useState } from 'react';
import type { ExpertProject } from '../ExpertProfileTab';
import { ProjectModal } from './ProjectModal';
import styles from '../ExpertProfileTab.module.css';
import api from '../../api/client';
interface ProjectsSectionProps {
projects: ExpertProject[];
onUpdate: () => Promise<void>;
}
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
function formatPeriod(p: ExpertProject): string {
const from = `${MONTH_NAMES[p.fromMonth - 1]} ${p.fromYear}`;
if (p.isCurrent) return `${from} heute`;
if (p.toMonth && p.toYear) return `${from} ${MONTH_NAMES[p.toMonth - 1]} ${p.toYear}`;
return from;
}
export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editProject, setEditProject] = useState<ExpertProject | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleEdit = (project: ExpertProject) => {
setEditProject(project);
setModalOpen(true);
};
const handleAdd = () => {
setEditProject(null);
setModalOpen(true);
};
const handleDelete = async (id: string) => {
setLoading(true);
setError('');
try {
await api.delete(`/expert-profile/me/projects/${id}`);
await onUpdate();
} catch {
setError('Fehler beim Löschen');
} finally {
setLoading(false);
}
};
const handleModalClose = () => {
setModalOpen(false);
setEditProject(null);
};
const handleModalSave = async () => {
handleModalClose();
await onUpdate();
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Projekthistorie</h3>
<button type="button" className={styles.btnPrimary} onClick={handleAdd}>
+ Projekt hinzufügen
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
{projects.length > 0 ? (
<div className={styles.entryList}>
{projects.map((project) => (
<div key={project.id} className={styles.entryItem}>
<div className={styles.entryInfo}>
<span className={styles.entrySecondary}>{formatPeriod(project)}</span>
<span className={styles.entryPrimary}>{project.role}</span>
{project.company && (
<span className={styles.entrySecondary}>{project.company}</span>
)}
</div>
<div className={styles.entryActions}>
<button
type="button"
className={styles.btnIcon}
onClick={() => handleEdit(project)}
disabled={loading}
title="Bearbeiten"
>
</button>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(project.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
) : (
<p className={styles.emptyState}>Noch keine Projekte hinzugefügt</p>
)}
<ProjectModal
isOpen={modalOpen}
onClose={handleModalClose}
onSave={handleModalSave}
project={editProject}
/>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useState, type KeyboardEvent } from 'react';
import api from '../../api/client';
import styles from '../ExpertProfileTab.module.css';
interface SkillsSectionProps {
skills: string[];
onUpdate: () => Promise<void>;
}
export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
const [newSkill, setNewSkill] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleAdd = async () => {
const skill = newSkill.trim();
if (!skill) return;
if (skills.includes(skill)) {
setError('Skill bereits vorhanden');
return;
}
setLoading(true);
setError('');
try {
await api.patch('/expert-profile/me/skills', { skills: [...skills, skill] });
setNewSkill('');
await onUpdate();
} catch {
setError('Fehler beim Hinzufügen');
} finally {
setLoading(false);
}
};
const handleRemove = async (skillToRemove: string) => {
setLoading(true);
setError('');
try {
await api.patch('/expert-profile/me/skills', {
skills: skills.filter((s) => s !== skillToRemove),
});
await onUpdate();
} catch {
setError('Fehler beim Entfernen');
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>Skills</h3>
</div>
{error && <div className={styles.error}>{error}</div>}
{skills.length > 0 ? (
<div className={styles.chipContainer}>
{skills.map((skill) => (
<span key={skill} className={styles.chip}>
{skill}
<button
type="button"
className={styles.chipRemove}
onClick={() => handleRemove(skill)}
disabled={loading}
aria-label={`${skill} entfernen`}
>
×
</button>
</span>
))}
</div>
) : (
<p className={styles.emptyState}>Noch keine Skills hinzugefügt</p>
)}
<div className={styles.chipInput}>
<input
type="text"
value={newSkill}
onChange={(e) => setNewSkill(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Neuer Skill..."
maxLength={100}
disabled={loading}
/>
<button
type="button"
className={styles.btnPrimary}
onClick={handleAdd}
disabled={loading || !newSkill.trim()}
>
Hinzufügen
</button>
</div>
</div>
);
}