mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 21:16:40 +02:00
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:
parent
5d3958cd74
commit
b326081c54
28 changed files with 2913 additions and 11 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ async function bootstrap(): Promise<void> {
|
|||
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(',') ?? [
|
||||
|
|
|
|||
86
packages/frontend/src/components/Modal.module.css
Normal file
86
packages/frontend/src/components/Modal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
60
packages/frontend/src/components/Modal.tsx
Normal file
60
packages/frontend/src/components/Modal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
469
packages/frontend/src/profile/ExpertProfileTab.module.css
Normal file
469
packages/frontend/src/profile/ExpertProfileTab.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
111
packages/frontend/src/profile/ExpertProfileTab.tsx
Normal file
111
packages/frontend/src/profile/ExpertProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* === Tab: Experten Profil (Platzhalter) === */}
|
||||
{activeTab === 'expert' && (
|
||||
<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: Experten Profil === */}
|
||||
{activeTab === 'expert' && <ExpertProfileTab />}
|
||||
|
||||
{/* === Tab: Passwort ändern + 2FA === */}
|
||||
{activeTab === 'password' && (
|
||||
|
|
|
|||
165
packages/frontend/src/profile/sections/AttachmentsSection.tsx
Normal file
165
packages/frontend/src/profile/sections/AttachmentsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
packages/frontend/src/profile/sections/CertificationModal.tsx
Normal file
134
packages/frontend/src/profile/sections/CertificationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
packages/frontend/src/profile/sections/CertificationsSection.tsx
Normal file
106
packages/frontend/src/profile/sections/CertificationsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
packages/frontend/src/profile/sections/ExperienceSection.tsx
Normal file
129
packages/frontend/src/profile/sections/ExperienceSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/frontend/src/profile/sections/LanguagesSection.tsx
Normal file
114
packages/frontend/src/profile/sections/LanguagesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
packages/frontend/src/profile/sections/ProjectModal.tsx
Normal file
243
packages/frontend/src/profile/sections/ProjectModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
packages/frontend/src/profile/sections/ProjectsSection.tsx
Normal file
117
packages/frontend/src/profile/sections/ProjectsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
packages/frontend/src/profile/sections/SkillsSection.tsx
Normal file
108
packages/frontend/src/profile/sections/SkillsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue