mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:06:38 +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[]
|
authProvider AuthProvider[]
|
||||||
tenantMemberships TenantMembership[]
|
tenantMemberships TenantMembership[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
expertProfile ExpertProfile?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -190,3 +191,137 @@ model AuditLog {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertProfile - Experten-Profil (1:1 mit User)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertProfile {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @unique @map("user_id") @db.Uuid
|
||||||
|
|
||||||
|
// Skills als Tag-Array
|
||||||
|
skills String[] @default([])
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
experiences ExpertExperience[]
|
||||||
|
languages ExpertLanguage[]
|
||||||
|
projects ExpertProject[]
|
||||||
|
certifications ExpertCertification[]
|
||||||
|
attachments ExpertAttachment[]
|
||||||
|
|
||||||
|
@@map("expert_profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertExperience - Erfahrung / Expertise-Bereiche
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertExperience {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
expertProfileId String @map("expert_profile_id") @db.Uuid
|
||||||
|
area String @db.VarChar(200) // z.B. "IT Infrastruktur"
|
||||||
|
years Int // Jahre Erfahrung
|
||||||
|
level String? @db.VarChar(50) // Experte, Fortgeschritten, Grundkenntnisse
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("expert_experiences")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertLanguage - Sprachen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertLanguage {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
expertProfileId String @map("expert_profile_id") @db.Uuid
|
||||||
|
language String @db.VarChar(100) // z.B. "Deutsch"
|
||||||
|
level String @db.VarChar(20) // Muttersprache, C2, C1, B2, B1, A2, A1
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("expert_languages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertProject - Projekthistorie
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertProject {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
expertProfileId String @map("expert_profile_id") @db.Uuid
|
||||||
|
|
||||||
|
// Zeitraum
|
||||||
|
fromMonth Int @map("from_month") // 1-12
|
||||||
|
fromYear Int @map("from_year") // z.B. 2023
|
||||||
|
toMonth Int? @map("to_month") // null wenn isCurrent
|
||||||
|
toYear Int? @map("to_year")
|
||||||
|
isCurrent Boolean @default(false) @map("is_current")
|
||||||
|
|
||||||
|
// Details
|
||||||
|
role String @db.VarChar(200) // Taetigkeit
|
||||||
|
tasks String? @db.Text // Aufgaben (max 1500 Zeichen im DTO)
|
||||||
|
company String? @db.VarChar(200) // Firma
|
||||||
|
companySize String? @map("company_size") @db.VarChar(20) // "1-10", "11-50", etc.
|
||||||
|
industry String? @db.VarChar(200) // Branche
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([expertProfileId, fromYear, fromMonth])
|
||||||
|
@@map("expert_projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertCertification - Zertifizierungen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertCertification {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
expertProfileId String @map("expert_profile_id") @db.Uuid
|
||||||
|
|
||||||
|
title String @db.VarChar(300) // Titel
|
||||||
|
issuingBody String @map("issuing_body") @db.VarChar(300) // Zertifizierungsstelle
|
||||||
|
website String? @db.VarChar(500) // URL
|
||||||
|
issueYear Int @map("issue_year") // Ausstellungsjahr
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("expert_certifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ExpertAttachment - Profilanlagen (Dateien als Base64)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ExpertAttachment {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
expertProfileId String @map("expert_profile_id") @db.Uuid
|
||||||
|
|
||||||
|
filename String @db.VarChar(255)
|
||||||
|
mimetype String @db.VarChar(100)
|
||||||
|
size Int // Groesse in Bytes
|
||||||
|
data String @db.Text // Base64-Daten
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("expert_attachments")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { AuthModule } from './core/auth/auth.module';
|
||||||
import { UsersModule } from './core/users/users.module';
|
import { UsersModule } from './core/users/users.module';
|
||||||
import { TenantsModule } from './core/tenants/tenants.module';
|
import { TenantsModule } from './core/tenants/tenants.module';
|
||||||
|
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { validateConfig } from './config/env.validation';
|
import { validateConfig } from './config/env.validation';
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ import { validateConfig } from './config/env.validation';
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
|
ExpertProfileModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
||||||
|
|
|
||||||
|
|
@ -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(helmet());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Body size limit erhoehen fuer Base64 Avatar-Uploads (Standard ~100KB)
|
// Body size limit für Base64-Uploads (Avatare, Profilanlagen bis 10MB)
|
||||||
app.use(json({ limit: '1mb' }));
|
app.use(json({ limit: '12mb' }));
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
|
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
|
||||||
|
|
|
||||||
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 api from '../api/client';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
import { resizeImageToBase64 } from '../utils/imageUtils';
|
import { resizeImageToBase64 } from '../utils/imageUtils';
|
||||||
|
import { ExpertProfileTab } from './ExpertProfileTab';
|
||||||
import styles from './ProfilePage.module.css';
|
import styles from './ProfilePage.module.css';
|
||||||
|
|
||||||
type ProfileTab = 'personal' | 'expert' | 'password';
|
type ProfileTab = 'personal' | 'expert' | 'password';
|
||||||
|
|
@ -532,15 +533,8 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Tab: Experten Profil (Platzhalter) === */}
|
{/* === Tab: Experten Profil === */}
|
||||||
{activeTab === 'expert' && (
|
{activeTab === 'expert' && <ExpertProfileTab />}
|
||||||
<div className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>Experten Profil</h2>
|
|
||||||
<p className={styles.placeholder}>
|
|
||||||
Hier können Sie zukünftig Ihr Experten-Profil verwalten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* === Tab: Passwort ändern + 2FA === */}
|
{/* === Tab: Passwort ändern + 2FA === */}
|
||||||
{activeTab === 'password' && (
|
{activeTab === 'password' && (
|
||||||
|
|
|
||||||
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