mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(core+frontend): Profilzugriff-Gruppen für Admin mit delegierten Berechtigungen
- Neue Prisma-Modelle ProfileAccessGroup + ProfileAccessGroupMember mit canView/canExport/canEdit - Manuelle Migration 20260314_profile_access_groups - ProfileAccessModule: CRUD-Endpoints für Gruppen und Mitglieder (nur PLATFORM_ADMIN) - Neue Admin-Endpoints in ExpertProfileService/-Controller für alle Profil-Mutationen - verifyOwnership mit skipCheck-Parameter für Admin-Bypass - ExpertProfileTab + alle Section-Komponenten erhalten apiBase-Prop für Wiederverwendung - AdminProfileAccessPage: Gruppen-Tab (CRUD) + Profile-Tab (alle User mit Aktionen) - AdminProfileDetailPage: Profil eines beliebigen Users im Admin-Kontext bearbeiten - Route /admin/profile-access + /admin/profiles/:userId + Nav-Tab Profilzugriff Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f7736a6fca
commit
c8b25321e7
21 changed files with 1300 additions and 32 deletions
|
|
@ -57,10 +57,11 @@ model User {
|
||||||
|
|
||||||
// Relationen
|
// Relationen
|
||||||
authProvider AuthProvider[]
|
authProvider AuthProvider[]
|
||||||
tenantMemberships TenantMembership[]
|
tenantMemberships TenantMembership[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
expertProfile ExpertProfile?
|
expertProfile ExpertProfile?
|
||||||
integrations UserIntegration[]
|
integrations UserIntegration[]
|
||||||
|
profileAccessGroups ProfileAccessGroupMember[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +91,38 @@ model UserIntegration {
|
||||||
@@map("user_integrations")
|
@@map("user_integrations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ProfileAccessGroup - Benutzergruppen fuer Profilzugriff
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ProfileAccessGroup {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
canView Boolean @default(true) @map("can_view")
|
||||||
|
canExport Boolean @default(false) @map("can_export")
|
||||||
|
canEdit Boolean @default(false) @map("can_edit")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
members ProfileAccessGroupMember[]
|
||||||
|
|
||||||
|
@@map("profile_access_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProfileAccessGroupMember {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
groupId String @map("group_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
group ProfileAccessGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([groupId, userId])
|
||||||
|
@@map("profile_access_group_members")
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// AuthProvider - Authentifizierungs-Provider pro User
|
// AuthProvider - Authentifizierungs-Provider pro User
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "profile_access_groups" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"name" VARCHAR(100) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"can_view" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"can_export" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"can_edit" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "profile_access_groups_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "profile_access_group_members" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"group_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "profile_access_group_members_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "profile_access_group_members_group_id_user_id_key" ON "profile_access_group_members"("group_id", "user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "profile_access_group_members" ADD CONSTRAINT "profile_access_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "profile_access_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "profile_access_group_members" ADD CONSTRAINT "profile_access_group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -12,6 +12,7 @@ import { TenantsModule } from './core/tenants/tenants.module';
|
||||||
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
||||||
import { SettingsModule } from './core/settings/settings.module';
|
import { SettingsModule } from './core/settings/settings.module';
|
||||||
import { IntegrationsModule } from './core/integrations/integrations.module';
|
import { IntegrationsModule } from './core/integrations/integrations.module';
|
||||||
|
import { ProfileAccessModule } from './core/profile-access/profile-access.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';
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ import { validateConfig } from './config/env.validation';
|
||||||
ExpertProfileModule,
|
ExpertProfileModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
|
ProfileAccessModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { Response } from 'express';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { ExpertProfileService } from './expert-profile.service';
|
import { ExpertProfileService } from './expert-profile.service';
|
||||||
import { ProfileExportService } from './profile-export.service';
|
import { ProfileExportService } from './profile-export.service';
|
||||||
|
import { ProfileAccessService } from '../profile-access/profile-access.service';
|
||||||
import { UpdateSkillsDto } from './dto/update-skills.dto';
|
import { UpdateSkillsDto } from './dto/update-skills.dto';
|
||||||
import { CreateExperienceDto } from './dto/create-experience.dto';
|
import { CreateExperienceDto } from './dto/create-experience.dto';
|
||||||
import { CreateLanguageDto } from './dto/create-language.dto';
|
import { CreateLanguageDto } from './dto/create-language.dto';
|
||||||
|
|
@ -32,6 +33,7 @@ export class ExpertProfileController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly expertProfileService: ExpertProfileService,
|
private readonly expertProfileService: ExpertProfileService,
|
||||||
private readonly profileExportService: ProfileExportService,
|
private readonly profileExportService: ProfileExportService,
|
||||||
|
private readonly profileAccessService: ProfileAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -230,4 +232,190 @@ export class ExpertProfileController {
|
||||||
) {
|
) {
|
||||||
await this.expertProfileService.deleteAttachment(userId, id);
|
await this.expertProfileService.deleteAttachment(userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Admin-Zugriff: alle Nutzerprofile (PLATFORM_ADMIN oder Gruppe)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@Get('admin/users')
|
||||||
|
@ApiOperation({ summary: 'Alle Nutzer mit Profilübersicht (Admin)' })
|
||||||
|
async adminListUsers(@CurrentUser('sub') requestingUserId: string) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canView');
|
||||||
|
return this.profileAccessService.getAllUsersWithProfileSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users/:userId')
|
||||||
|
@ApiOperation({ summary: 'Profil eines Nutzers abrufen (Admin)' })
|
||||||
|
async adminGetProfile(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canView');
|
||||||
|
return this.expertProfileService.getOrCreateProfileAdmin(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users/:userId/export/pdf')
|
||||||
|
@ApiOperation({ summary: 'Profil als PDF exportieren (Admin)' })
|
||||||
|
async adminExportPdf(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canExport');
|
||||||
|
const { buffer, firstName, lastName } = await this.profileExportService.generatePdf(targetUserId);
|
||||||
|
const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_');
|
||||||
|
const encodedName = encodeURIComponent(`${baseName}.pdf`);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${baseName}.pdf"; filename*=UTF-8''${encodedName}`,
|
||||||
|
'Content-Length': String(buffer.length),
|
||||||
|
});
|
||||||
|
res.end(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users/:userId/export/docx')
|
||||||
|
@ApiOperation({ summary: 'Profil als Word exportieren (Admin)' })
|
||||||
|
async adminExportDocx(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canExport');
|
||||||
|
const { buffer, firstName, lastName } = await this.profileExportService.generateDocx(targetUserId);
|
||||||
|
const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_');
|
||||||
|
const encodedName = encodeURIComponent(`${baseName}.docx`);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'Content-Disposition': `attachment; filename="${baseName}.docx"; filename*=UTF-8''${encodedName}`,
|
||||||
|
'Content-Length': String(buffer.length),
|
||||||
|
});
|
||||||
|
res.end(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/users/:userId/skills')
|
||||||
|
@ApiOperation({ summary: 'Skills eines Nutzers aktualisieren (Admin)' })
|
||||||
|
async adminUpdateSkills(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Body() dto: UpdateSkillsDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.updateSkillsAdmin(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/users/:userId/experiences')
|
||||||
|
@ApiOperation({ summary: 'Erfahrung eines Nutzers hinzufügen (Admin)' })
|
||||||
|
async adminAddExperience(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Body() dto: CreateExperienceDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.addExperienceAdmin(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:userId/experiences/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Erfahrung eines Nutzers löschen (Admin)' })
|
||||||
|
async adminDeleteExperience(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
await this.expertProfileService.deleteExperienceAdmin(targetUserId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/users/:userId/languages')
|
||||||
|
@ApiOperation({ summary: 'Sprache eines Nutzers hinzufügen (Admin)' })
|
||||||
|
async adminAddLanguage(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Body() dto: CreateLanguageDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.addLanguageAdmin(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:userId/languages/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Sprache eines Nutzers löschen (Admin)' })
|
||||||
|
async adminDeleteLanguage(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
await this.expertProfileService.deleteLanguageAdmin(targetUserId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/users/:userId/projects')
|
||||||
|
@ApiOperation({ summary: 'Projekt eines Nutzers hinzufügen (Admin)' })
|
||||||
|
async adminAddProject(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Body() dto: CreateProjectDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.addProjectAdmin(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/users/:userId/projects/:id')
|
||||||
|
@ApiOperation({ summary: 'Projekt eines Nutzers bearbeiten (Admin)' })
|
||||||
|
async adminUpdateProject(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateProjectDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.updateProjectAdmin(targetUserId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:userId/projects/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Projekt eines Nutzers löschen (Admin)' })
|
||||||
|
async adminDeleteProject(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
await this.expertProfileService.deleteProjectAdmin(targetUserId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/users/:userId/certifications')
|
||||||
|
@ApiOperation({ summary: 'Zertifizierung eines Nutzers hinzufügen (Admin)' })
|
||||||
|
async adminAddCertification(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Body() dto: CreateCertificationDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.addCertificationAdmin(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/users/:userId/certifications/:id')
|
||||||
|
@ApiOperation({ summary: 'Zertifizierung eines Nutzers bearbeiten (Admin)' })
|
||||||
|
async adminUpdateCertification(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCertificationDto,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
return this.expertProfileService.updateCertificationAdmin(targetUserId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:userId/certifications/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Zertifizierung eines Nutzers löschen (Admin)' })
|
||||||
|
async adminDeleteCertification(
|
||||||
|
@CurrentUser('sub') requestingUserId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) targetUserId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
await this.profileAccessService.assertAccess(requestingUserId, 'canEdit');
|
||||||
|
await this.expertProfileService.deleteCertificationAdmin(targetUserId, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||||
import { ExpertProfileController } from './expert-profile.controller';
|
import { ExpertProfileController } from './expert-profile.controller';
|
||||||
import { ExpertProfileService } from './expert-profile.service';
|
import { ExpertProfileService } from './expert-profile.service';
|
||||||
import { ProfileExportService } from './profile-export.service';
|
import { ProfileExportService } from './profile-export.service';
|
||||||
|
import { ProfileAccessModule } from '../profile-access/profile-access.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProfileAccessModule],
|
||||||
controllers: [ExpertProfileController],
|
controllers: [ExpertProfileController],
|
||||||
providers: [ExpertProfileService, ProfileExportService],
|
providers: [ExpertProfileService, ProfileExportService],
|
||||||
exports: [ExpertProfileService],
|
exports: [ExpertProfileService],
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,10 @@ export class ExpertProfileService {
|
||||||
userId: string,
|
userId: string,
|
||||||
model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification',
|
model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification',
|
||||||
entityId: string,
|
entityId: string,
|
||||||
|
skipCheck = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (skipCheck) return;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const entity = await (this.prisma[model] as any).findUnique({
|
const entity = await (this.prisma[model] as any).findUnique({
|
||||||
where: { id: entityId },
|
where: { id: entityId },
|
||||||
|
|
@ -348,4 +351,87 @@ export class ExpertProfileService {
|
||||||
throw new ForbiddenException('Kein Zugriff auf diesen Eintrag');
|
throw new ForbiddenException('Kein Zugriff auf diesen Eintrag');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Admin-Methoden (kein Ownership-Check)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async getOrCreateProfileAdmin(targetUserId: string) {
|
||||||
|
return this.getOrCreateProfile(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSkillsAdmin(targetUserId: string, dto: UpdateSkillsDto) {
|
||||||
|
return this.updateSkills(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addExperienceAdmin(targetUserId: string, dto: CreateExperienceDto) {
|
||||||
|
return this.addExperience(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExperienceAdmin(targetUserId: string, experienceId: string) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertExperience', experienceId, true);
|
||||||
|
await this.prisma.expertExperience.delete({ where: { id: experienceId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLanguageAdmin(targetUserId: string, dto: CreateLanguageDto) {
|
||||||
|
return this.addLanguage(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLanguageAdmin(targetUserId: string, languageId: string) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertLanguage', languageId, true);
|
||||||
|
await this.prisma.expertLanguage.delete({ where: { id: languageId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addProjectAdmin(targetUserId: string, dto: CreateProjectDto) {
|
||||||
|
return this.addProject(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProjectAdmin(targetUserId: string, projectId: string, dto: UpdateProjectDto) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertProject', projectId, true);
|
||||||
|
return this.prisma.expertProject.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
...(dto.role !== undefined && { role: dto.role }),
|
||||||
|
...(dto.company !== undefined && { company: dto.company }),
|
||||||
|
...(dto.industry !== undefined && { industry: dto.industry }),
|
||||||
|
...(dto.fromMonth !== undefined && { fromMonth: dto.fromMonth }),
|
||||||
|
...(dto.fromYear !== undefined && { fromYear: dto.fromYear }),
|
||||||
|
...(dto.toMonth !== undefined && { toMonth: dto.toMonth }),
|
||||||
|
...(dto.toYear !== undefined && { toYear: dto.toYear }),
|
||||||
|
...(dto.isCurrent !== undefined && { isCurrent: dto.isCurrent }),
|
||||||
|
...(dto.tasks !== undefined && { tasks: dto.tasks }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProjectAdmin(targetUserId: string, projectId: string) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertProject', projectId, true);
|
||||||
|
await this.prisma.expertProject.delete({ where: { id: projectId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCertificationAdmin(targetUserId: string, dto: CreateCertificationDto) {
|
||||||
|
return this.addCertification(targetUserId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCertificationAdmin(
|
||||||
|
targetUserId: string,
|
||||||
|
certificationId: string,
|
||||||
|
dto: UpdateCertificationDto,
|
||||||
|
) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertCertification', certificationId, true);
|
||||||
|
return this.prisma.expertCertification.update({
|
||||||
|
where: { id: certificationId },
|
||||||
|
data: {
|
||||||
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
|
...(dto.issuingBody !== undefined && { issuingBody: dto.issuingBody }),
|
||||||
|
...(dto.issueYear !== undefined && { issueYear: dto.issueYear }),
|
||||||
|
...(dto.website !== undefined && { website: dto.website }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCertificationAdmin(targetUserId: string, certificationId: string) {
|
||||||
|
await this.verifyOwnership(targetUserId, 'expertCertification', certificationId, true);
|
||||||
|
await this.prisma.expertCertification.delete({ where: { id: certificationId } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsBoolean, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ProfileAccessService } from './profile-access.service';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
|
||||||
|
class CreateGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canView!: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canExport!: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canEdit!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateGroupDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canView?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canExport?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddMemberDto {
|
||||||
|
@IsString()
|
||||||
|
userId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Profilzugriff')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Roles('PLATFORM_ADMIN')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Controller('profile-access')
|
||||||
|
export class ProfileAccessController {
|
||||||
|
constructor(private readonly profileAccessService: ProfileAccessService) {}
|
||||||
|
|
||||||
|
@Get('groups')
|
||||||
|
getAllGroups() {
|
||||||
|
return this.profileAccessService.getAllGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups')
|
||||||
|
createGroup(@Body() dto: CreateGroupDto) {
|
||||||
|
return this.profileAccessService.createGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('groups/:id')
|
||||||
|
updateGroup(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateGroupDto) {
|
||||||
|
return this.profileAccessService.updateGroup(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('groups/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
deleteGroup(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.profileAccessService.deleteGroup(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id/members')
|
||||||
|
getGroupMembers(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.profileAccessService.getGroupMembers(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:id/members')
|
||||||
|
addMember(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddMemberDto) {
|
||||||
|
return this.profileAccessService.addMember(id, dto.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('groups/:id/members/:userId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
removeMember(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
) {
|
||||||
|
return this.profileAccessService.removeMember(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
getAllUsersForPicker() {
|
||||||
|
return this.profileAccessService.getAllUsersForPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProfileAccessController } from './profile-access.controller';
|
||||||
|
import { ProfileAccessService } from './profile-access.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProfileAccessController],
|
||||||
|
providers: [ProfileAccessService],
|
||||||
|
exports: [ProfileAccessService],
|
||||||
|
})
|
||||||
|
export class ProfileAccessModule {}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { Injectable, NotFoundException, ConflictException, ForbiddenException, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface ProfileAccessGroupDto {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
canView: boolean;
|
||||||
|
canExport: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProfileAccessService {
|
||||||
|
private readonly logger = new Logger(ProfileAccessService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getAllGroups() {
|
||||||
|
const groups = await this.prisma.profileAccessGroup.findMany({
|
||||||
|
include: { _count: { select: { members: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
description: g.description,
|
||||||
|
canView: g.canView,
|
||||||
|
canExport: g.canExport,
|
||||||
|
canEdit: g.canEdit,
|
||||||
|
memberCount: g._count.members,
|
||||||
|
createdAt: g.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGroup(dto: ProfileAccessGroupDto) {
|
||||||
|
return this.prisma.profileAccessGroup.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
canView: dto.canView,
|
||||||
|
canExport: dto.canExport,
|
||||||
|
canEdit: dto.canEdit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGroup(id: string, dto: Partial<ProfileAccessGroupDto>) {
|
||||||
|
await this.findGroupOrThrow(id);
|
||||||
|
return this.prisma.profileAccessGroup.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.canView !== undefined && { canView: dto.canView }),
|
||||||
|
...(dto.canExport !== undefined && { canExport: dto.canExport }),
|
||||||
|
...(dto.canEdit !== undefined && { canEdit: dto.canEdit }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroup(id: string) {
|
||||||
|
await this.findGroupOrThrow(id);
|
||||||
|
await this.prisma.profileAccessGroup.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembers(groupId: string) {
|
||||||
|
await this.findGroupOrThrow(groupId);
|
||||||
|
const members = await this.prisma.profileAccessGroupMember.findMany({
|
||||||
|
where: { groupId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
jobTitle: true,
|
||||||
|
department: true,
|
||||||
|
avatar: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return members.map((m) => ({ memberId: m.id, ...m.user }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(groupId: string, userId: string) {
|
||||||
|
await this.findGroupOrThrow(groupId);
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('Benutzer nicht gefunden');
|
||||||
|
try {
|
||||||
|
await this.prisma.profileAccessGroupMember.create({ data: { groupId, userId } });
|
||||||
|
} catch {
|
||||||
|
throw new ConflictException('Benutzer ist bereits Mitglied dieser Gruppe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(groupId: string, userId: string) {
|
||||||
|
const member = await this.prisma.profileAccessGroupMember.findUnique({
|
||||||
|
where: { groupId_userId: { groupId, userId } },
|
||||||
|
});
|
||||||
|
if (!member) throw new NotFoundException('Mitglied nicht gefunden');
|
||||||
|
await this.prisma.profileAccessGroupMember.delete({ where: { id: member.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob ein User Zugriff auf alle Profile hat.
|
||||||
|
* PLATFORM_ADMIN hat immer Zugriff.
|
||||||
|
* Sonstige User müssen in einer Gruppe mit der jeweiligen Berechtigung sein.
|
||||||
|
*/
|
||||||
|
async checkAccess(
|
||||||
|
userId: string,
|
||||||
|
permission: 'canView' | 'canExport' | 'canEdit',
|
||||||
|
): Promise<boolean> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.role === 'PLATFORM_ADMIN') return true;
|
||||||
|
|
||||||
|
const membership = await this.prisma.profileAccessGroupMember.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
group: { [permission]: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return membership !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertAccess(
|
||||||
|
userId: string,
|
||||||
|
permission: 'canView' | 'canExport' | 'canEdit',
|
||||||
|
): Promise<void> {
|
||||||
|
const hasAccess = await this.checkAccess(userId, permission);
|
||||||
|
if (!hasAccess) throw new ForbiddenException('Keine Berechtigung für diesen Zugriff');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllUsersWithProfileSummary() {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
jobTitle: true,
|
||||||
|
department: true,
|
||||||
|
avatar: true,
|
||||||
|
expertProfile: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
skills: true,
|
||||||
|
_count: { select: { projects: true, certifications: true, experiences: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
firstName: u.firstName,
|
||||||
|
lastName: u.lastName,
|
||||||
|
email: u.email,
|
||||||
|
jobTitle: u.jobTitle,
|
||||||
|
department: u.department,
|
||||||
|
avatar: u.avatar,
|
||||||
|
hasProfile: u.expertProfile !== null,
|
||||||
|
profileId: u.expertProfile?.id ?? null,
|
||||||
|
skills: u.expertProfile?.skills ?? [],
|
||||||
|
projectCount: u.expertProfile?._count.projects ?? 0,
|
||||||
|
certificationCount: u.expertProfile?._count.certifications ?? 0,
|
||||||
|
experienceCount: u.expertProfile?._count.experiences ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllUsersForPicker() {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
jobTitle: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findGroupOrThrow(id: string) {
|
||||||
|
const group = await this.prisma.profileAccessGroup.findUnique({ where: { id } });
|
||||||
|
if (!group) throw new NotFoundException('Gruppe nicht gefunden');
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ const tabs = [
|
||||||
{ to: '/admin/company', label: 'Firmendaten' },
|
{ to: '/admin/company', label: 'Firmendaten' },
|
||||||
{ to: '/admin/events', label: 'Events' },
|
{ to: '/admin/events', label: 'Events' },
|
||||||
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
||||||
|
{ to: '/admin/profile-access', label: 'Profilzugriff' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
|
|
|
||||||
477
packages/frontend/src/admin/AdminProfileAccessPage.tsx
Normal file
477
packages/frontend/src/admin/AdminProfileAccessPage.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================
|
||||||
|
interface ProfileAccessGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
canView: boolean;
|
||||||
|
canExport: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
memberId: string;
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserForPicker {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserWithProfile {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
hasProfile: boolean;
|
||||||
|
profileId: string | null;
|
||||||
|
skills: string[];
|
||||||
|
projectCount: number;
|
||||||
|
certificationCount: number;
|
||||||
|
experienceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Styles (inline)
|
||||||
|
// ============================================================
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '1.25rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeStyle = (active: boolean, color: string): React.CSSProperties => ({
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: active ? color + '22' : 'var(--color-bg-hover)',
|
||||||
|
color: active ? color : 'var(--color-text-muted)',
|
||||||
|
border: `1px solid ${active ? color + '44' : 'transparent'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnPrimary: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1.25rem',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnSecondary: React.CSSProperties = {
|
||||||
|
padding: '0.4rem 0.875rem',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnDanger: React.CSSProperties = {
|
||||||
|
...btnSecondary,
|
||||||
|
color: 'var(--color-danger, #dc2626)',
|
||||||
|
borderColor: 'var(--color-danger, #dc2626)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GroupDialog
|
||||||
|
// ============================================================
|
||||||
|
interface GroupFormData { name: string; description: string; canView: boolean; canExport: boolean; canEdit: boolean; }
|
||||||
|
const emptyGroupForm: GroupFormData = { name: '', description: '', canView: true, canExport: false, canEdit: false };
|
||||||
|
|
||||||
|
function GroupDialog({ group, onClose }: { group: ProfileAccessGroup | null; onClose: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [form, setForm] = useState<GroupFormData>(
|
||||||
|
group ? { name: group.name, description: group.description ?? '', canView: group.canView, canExport: group.canExport, canEdit: group.canEdit } : emptyGroupForm
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: GroupFormData) =>
|
||||||
|
group
|
||||||
|
? api.patch(`/profile-access/groups/${group.id}`, data).then((r) => r.data)
|
||||||
|
: api.post('/profile-access/groups', data).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['profile-access-groups'] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlayStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
};
|
||||||
|
const dialogStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--color-bg-card)', borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-lg)', padding: '1.5rem', width: 420, maxWidth: '95vw',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlayStyle} onClick={onClose}>
|
||||||
|
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 style={{ margin: '0 0 1.25rem', fontSize: '1rem', fontWeight: 700 }}>
|
||||||
|
{group ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '0.875rem' }}>
|
||||||
|
<label style={labelStyle}>Gruppenname *</label>
|
||||||
|
<input style={inputStyle} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="z.B. HR-Team" />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Beschreibung</label>
|
||||||
|
<input style={inputStyle} value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="Optionale Beschreibung" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<label style={labelStyle}>Berechtigungen</label>
|
||||||
|
{(['canView', 'canExport', 'canEdit'] as const).map((key) => (
|
||||||
|
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem', cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
<input type="checkbox" checked={form[key]} onChange={(e) => setForm((p) => ({ ...p, [key]: e.target.checked }))} />
|
||||||
|
{{ canView: 'Ansehen', canExport: 'Exportieren', canEdit: 'Bearbeiten' }[key]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button style={btnSecondary} onClick={onClose}>Abbrechen</button>
|
||||||
|
<button style={btnPrimary} disabled={!form.name.trim() || mutation.isPending} onClick={() => mutation.mutate(form)}>
|
||||||
|
{mutation.isPending ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mutation.isError && <p style={{ color: 'var(--color-danger, #dc2626)', fontSize: '0.8rem', marginTop: '0.5rem' }}>Fehler beim Speichern</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GroupCard
|
||||||
|
// ============================================================
|
||||||
|
function GroupCard({ group, allUsers }: { group: ProfileAccessGroup; allUsers: UserForPicker[] }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [pickerUserId, setPickerUserId] = useState('');
|
||||||
|
|
||||||
|
const { data: members = [] } = useQuery<GroupMember[]>({
|
||||||
|
queryKey: ['profile-access-group-members', group.id],
|
||||||
|
queryFn: () => api.get<GroupMember[]>(`/profile-access/groups/${group.id}/members`).then((r) => r.data),
|
||||||
|
enabled: expanded,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/profile-access/groups/${group.id}`),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ['profile-access-groups'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMemberMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => api.post(`/profile-access/groups/${group.id}/members`, { userId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['profile-access-group-members', group.id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['profile-access-groups'] });
|
||||||
|
setPickerUserId('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => api.delete(`/profile-access/groups/${group.id}/members/${userId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['profile-access-group-members', group.id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['profile-access-groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberUserIds = new Set(members.map((m) => m.id));
|
||||||
|
const availableUsers = allUsers.filter((u) => !memberUserIds.has(u.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{editing && <GroupDialog group={group} onClose={() => setEditing(false)} />}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '0.9375rem' }}>{group.name}</span>
|
||||||
|
<span style={badgeStyle(group.canView, '#2563eb')}>Ansehen</span>
|
||||||
|
<span style={badgeStyle(group.canExport, '#7c3aed')}>Exportieren</span>
|
||||||
|
<span style={badgeStyle(group.canEdit, '#059669')}>Bearbeiten</span>
|
||||||
|
</div>
|
||||||
|
{group.description && <p style={{ margin: '0 0 0.25rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{group.description}</p>}
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{group.memberCount} Mitglied{group.memberCount !== 1 ? 'er' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button style={btnSecondary} onClick={() => setExpanded((v) => !v)}>{expanded ? 'Schließen' : 'Mitglieder'}</button>
|
||||||
|
<button style={btnSecondary} onClick={() => setEditing(true)}>Bearbeiten</button>
|
||||||
|
<button style={btnDanger} disabled={deleteMutation.isPending} onClick={() => { if (confirm(`Gruppe "${group.name}" löschen?`)) deleteMutation.mutate(); }}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: '1rem', borderTop: '1px solid var(--color-border)', paddingTop: '0.875rem' }}>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p style={{ margin: '0 0 0.75rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>Keine Mitglieder</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
{members.map((m) => (
|
||||||
|
<span key={m.memberId} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', background: 'var(--color-bg-hover)', border: '1px solid var(--color-border)', borderRadius: '999px', padding: '3px 10px', fontSize: '0.8rem' }}>
|
||||||
|
{m.firstName} {m.lastName}
|
||||||
|
<button onClick={() => removeMemberMutation.mutate(m.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: 0, lineHeight: 1 }}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableUsers.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<select value={pickerUserId} onChange={(e) => setPickerUserId(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 'auto', flex: 1 }}>
|
||||||
|
<option value="">Benutzer hinzufügen…</option>
|
||||||
|
{availableUsers.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.email})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button style={btnPrimary} disabled={!pickerUserId || addMemberMutation.isPending}
|
||||||
|
onClick={() => pickerUserId && addMemberMutation.mutate(pickerUserId)}>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ProfileStatusBadge
|
||||||
|
// ============================================================
|
||||||
|
function ProfileStatus({ user }: { user: UserWithProfile }) {
|
||||||
|
if (!user.hasProfile) return <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Kein Profil</span>;
|
||||||
|
const complete = user.projectCount > 0 && user.skills.length > 0;
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 500, color: complete ? '#16a34a' : '#d97706' }}>
|
||||||
|
{complete ? '✓ Vollständig' : '⚠ Unvollständig'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Page
|
||||||
|
// ============================================================
|
||||||
|
export function AdminProfileAccessPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState<'groups' | 'profiles'>('groups');
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const [profileSearch, setProfileSearch] = useState('');
|
||||||
|
|
||||||
|
const { data: groups = [], isLoading: groupsLoading } = useQuery<ProfileAccessGroup[]>({
|
||||||
|
queryKey: ['profile-access-groups'],
|
||||||
|
queryFn: () => api.get<ProfileAccessGroup[]>('/profile-access/groups').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allUsers = [] } = useQuery<UserForPicker[]>({
|
||||||
|
queryKey: ['profile-access-users'],
|
||||||
|
queryFn: () => api.get<UserForPicker[]>('/profile-access/users').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: profileUsers = [], isLoading: profilesLoading } = useQuery<UserWithProfile[]>({
|
||||||
|
queryKey: ['expert-profile-admin-users'],
|
||||||
|
queryFn: () => api.get<UserWithProfile[]>('/expert-profile/admin/users').then((r) => r.data),
|
||||||
|
enabled: activeTab === 'profiles',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
borderBottom: `2px solid ${active ? 'var(--color-primary)' : 'transparent'}`,
|
||||||
|
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredProfiles = profileUsers.filter((u) => {
|
||||||
|
const q = profileSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
!q ||
|
||||||
|
u.firstName.toLowerCase().includes(q) ||
|
||||||
|
u.lastName.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
(u.department ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownload = async (userId: string, format: 'pdf' | 'docx', name: string) => {
|
||||||
|
const url = `/api/v1/expert-profile/admin/users/${userId}/export/${format}`;
|
||||||
|
const res = await api.get(url, { responseType: 'blob' });
|
||||||
|
const blob = new Blob([res.data as BlobPart]);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `${name}_CV.${format}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
{showCreateDialog && <GroupDialog group={null} onClose={() => setShowCreateDialog(false)} />}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: '0.25rem' }}>
|
||||||
|
<button style={tabStyle(activeTab === 'groups')} onClick={() => setActiveTab('groups')}>Gruppen</button>
|
||||||
|
<button style={tabStyle(activeTab === 'profiles')} onClick={() => setActiveTab('profiles')}>Profile</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Gruppen-Tab ── */}
|
||||||
|
{activeTab === 'groups' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>Berechtigungsgruppen</h2>
|
||||||
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
Gruppen mit Zugriff auf alle Expertenprofile verwalten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button style={btnPrimary} onClick={() => setShowCreateDialog(true)}>+ Neue Gruppe</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupsLoading ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>Lädt…</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<div style={{ ...cardStyle, textAlign: 'center', padding: '2.5rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<p style={{ margin: 0 }}>Noch keine Gruppen angelegt.</p>
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.8125rem' }}>Erstelle eine Gruppe, um Benutzern Zugriff auf alle Expertenprofile zu gewähren.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => <GroupCard key={g.id} group={g} allUsers={allUsers} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Profile-Tab ── */}
|
||||||
|
{activeTab === 'profiles' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1rem', fontWeight: 700 }}>Alle Expertenprofile</h2>
|
||||||
|
<p style={{ margin: '0 0 0.875rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
Ansehen, exportieren und bearbeiten
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, maxWidth: 320 }}
|
||||||
|
placeholder="Suchen nach Name, E-Mail, Abteilung…"
|
||||||
|
value={profileSearch}
|
||||||
|
onChange={(e) => setProfileSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profilesLoading ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>Lädt…</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--color-bg-hover)', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
{['Name', 'Abteilung', 'Profil-Status', 'Aktionen'].map((h) => (
|
||||||
|
<th key={h} style={{ padding: '0.625rem 1rem', textAlign: 'left', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredProfiles.map((u, idx) => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: idx < filteredProfiles.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||||
|
{u.avatar ? (
|
||||||
|
<img src={u.avatar} alt="" style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: '0.75rem', fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{u.firstName[0]}{u.lastName[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>{u.firstName} {u.lastName}</div>
|
||||||
|
{u.jobTitle && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>{u.jobTitle}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
{u.department ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<ProfileStatus user={u} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
|
||||||
|
<button style={btnSecondary} title="Profil bearbeiten" onClick={() => navigate(`/admin/profiles/${u.id}`)}>Bearbeiten</button>
|
||||||
|
{u.hasProfile && (
|
||||||
|
<>
|
||||||
|
<button style={btnSecondary} title="PDF herunterladen" onClick={() => void handleDownload(u.id, 'pdf', `${u.firstName}_${u.lastName}`)}>PDF</button>
|
||||||
|
<button style={btnSecondary} title="Word herunterladen" onClick={() => void handleDownload(u.id, 'docx', `${u.firstName}_${u.lastName}`)}>Word</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredProfiles.length === 0 && (
|
||||||
|
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>Keine Ergebnisse</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
packages/frontend/src/admin/AdminProfileDetailPage.tsx
Normal file
100
packages/frontend/src/admin/AdminProfileDetailPage.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { ExpertProfileTab } from '../profile/ExpertProfileTab';
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
department?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminProfileDetailPage() {
|
||||||
|
const { userId } = useParams<{ userId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [user, setUser] = useState<UserInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
api
|
||||||
|
.get<UserInfo[]>('/expert-profile/admin/users')
|
||||||
|
.then(({ data }) => {
|
||||||
|
const found = data.find((u) => u.id === userId);
|
||||||
|
if (found) setUser(found);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// user info is optional — profile still renders
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const apiBase = `/expert-profile/admin/users/${userId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/admin/profile-access')}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#2563eb',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0 0 1rem 0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Zurück zu Profilzugriff
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={`${user.firstName} ${user.lastName}`}
|
||||||
|
style={{ width: 56, height: 56, borderRadius: '50%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#6b7280',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.firstName[0]}{user.lastName[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700 }}>
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>
|
||||||
|
{[user.jobTitle, user.department].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '0.75rem' }}>{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExpertProfileTab apiBase={apiBase} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,11 @@ interface ExpertProfile {
|
||||||
|
|
||||||
export type { ExpertExperience, ExpertLanguage, ExpertProject, ExpertCertification, AttachmentMeta };
|
export type { ExpertExperience, ExpertLanguage, ExpertProject, ExpertCertification, AttachmentMeta };
|
||||||
|
|
||||||
export function ExpertProfileTab() {
|
interface ExpertProfileTabProps {
|
||||||
|
apiBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpertProfileTab({ apiBase = '/expert-profile/me' }: ExpertProfileTabProps = {}) {
|
||||||
const [profile, setProfile] = useState<ExpertProfile | null>(null);
|
const [profile, setProfile] = useState<ExpertProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
@ -71,7 +75,7 @@ export function ExpertProfileTab() {
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
const loadProfile = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<ExpertProfile>('/expert-profile/me');
|
const { data } = await api.get<ExpertProfile>(apiBase);
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
setError('');
|
setError('');
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -79,7 +83,7 @@ export function ExpertProfileTab() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [apiBase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
|
@ -88,7 +92,7 @@ export function ExpertProfileTab() {
|
||||||
const handleExport = async (format: 'pdf' | 'docx') => {
|
const handleExport = async (format: 'pdf' | 'docx') => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/expert-profile/me/export/${format}`, {
|
const response = await api.get(`${apiBase}/export/${format}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
const blob = response.data as Blob;
|
const blob = response.data as Blob;
|
||||||
|
|
@ -145,12 +149,12 @@ export function ExpertProfileTab() {
|
||||||
|
|
||||||
{/* Skills + Sprachen nebeneinander */}
|
{/* Skills + Sprachen nebeneinander */}
|
||||||
<div className={styles.twoColumnRow}>
|
<div className={styles.twoColumnRow}>
|
||||||
<SkillsSection skills={profile.skills} onUpdate={loadProfile} />
|
<SkillsSection skills={profile.skills} onUpdate={loadProfile} apiBase={apiBase} />
|
||||||
<LanguagesSection languages={profile.languages} onUpdate={loadProfile} />
|
<LanguagesSection languages={profile.languages} onUpdate={loadProfile} apiBase={apiBase} />
|
||||||
</div>
|
</div>
|
||||||
<ExperienceSection experiences={profile.experiences} onUpdate={loadProfile} />
|
<ExperienceSection experiences={profile.experiences} onUpdate={loadProfile} apiBase={apiBase} />
|
||||||
<ProjectsSection projects={profile.projects} onUpdate={loadProfile} />
|
<ProjectsSection projects={profile.projects} onUpdate={loadProfile} apiBase={apiBase} />
|
||||||
<CertificationsSection certifications={profile.certifications} onUpdate={loadProfile} />
|
<CertificationsSection certifications={profile.certifications} onUpdate={loadProfile} apiBase={apiBase} />
|
||||||
<AttachmentsSection attachments={profile.attachments} onUpdate={loadProfile} />
|
<AttachmentsSection attachments={profile.attachments} onUpdate={loadProfile} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ interface CertificationModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => Promise<void>;
|
onSave: () => Promise<void>;
|
||||||
certification: ExpertCertification | null;
|
certification: ExpertCertification | null;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i);
|
const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i);
|
||||||
|
|
||||||
export function CertificationModal({ isOpen, onClose, onSave, certification }: CertificationModalProps) {
|
export function CertificationModal({ isOpen, onClose, onSave, certification, apiBase = '/expert-profile/me' }: CertificationModalProps) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [issuingBody, setIssuingBody] = useState('');
|
const [issuingBody, setIssuingBody] = useState('');
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
|
|
@ -53,9 +54,9 @@ export function CertificationModal({ isOpen, onClose, onSave, certification }: C
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (certification) {
|
if (certification) {
|
||||||
await api.patch(`/expert-profile/me/certifications/${certification.id}`, payload);
|
await api.patch(`${apiBase}/certifications/${certification.id}`, payload);
|
||||||
} else {
|
} else {
|
||||||
await api.post('/expert-profile/me/certifications', payload);
|
await api.post('${apiBase}/certifications', payload);
|
||||||
}
|
}
|
||||||
await onSave();
|
await onSave();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import api from '../../api/client';
|
||||||
interface CertificationsSectionProps {
|
interface CertificationsSectionProps {
|
||||||
certifications: ExpertCertification[];
|
certifications: ExpertCertification[];
|
||||||
onUpdate: () => Promise<void>;
|
onUpdate: () => Promise<void>;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CertificationsSection({ certifications, onUpdate }: CertificationsSectionProps) {
|
export function CertificationsSection({ certifications, onUpdate, apiBase = '/expert-profile/me' }: CertificationsSectionProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editCert, setEditCert] = useState<ExpertCertification | null>(null);
|
const [editCert, setEditCert] = useState<ExpertCertification | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -29,7 +30,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.delete(`/expert-profile/me/certifications/${id}`);
|
await api.delete(`${apiBase}/certifications/${id}`);
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fehler beim Löschen');
|
setError('Fehler beim Löschen');
|
||||||
|
|
@ -100,6 +101,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
onSave={handleModalSave}
|
onSave={handleModalSave}
|
||||||
certification={editCert}
|
certification={editCert}
|
||||||
|
apiBase={apiBase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import styles from '../ExpertProfileTab.module.css';
|
||||||
interface ExperienceSectionProps {
|
interface ExperienceSectionProps {
|
||||||
experiences: ExpertExperience[];
|
experiences: ExpertExperience[];
|
||||||
onUpdate: () => Promise<void>;
|
onUpdate: () => Promise<void>;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionProps) {
|
export function ExperienceSection({ experiences, onUpdate, apiBase = '/expert-profile/me' }: ExperienceSectionProps) {
|
||||||
const [area, setArea] = useState('');
|
const [area, setArea] = useState('');
|
||||||
const [years, setYears] = useState('');
|
const [years, setYears] = useState('');
|
||||||
const [level, setLevel] = useState('');
|
const [level, setLevel] = useState('');
|
||||||
|
|
@ -22,7 +23,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.post('/expert-profile/me/experiences', {
|
await api.post(`${apiBase}/experiences`, {
|
||||||
area: area.trim(),
|
area: area.trim(),
|
||||||
years: parseInt(years, 10),
|
years: parseInt(years, 10),
|
||||||
...(level && { level }),
|
...(level && { level }),
|
||||||
|
|
@ -42,7 +43,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.delete(`/expert-profile/me/experiences/${id}`);
|
await api.delete(`${apiBase}/experiences/${id}`);
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fehler beim Löschen');
|
setError('Fehler beim Löschen');
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ import styles from '../ExpertProfileTab.module.css';
|
||||||
interface LanguagesSectionProps {
|
interface LanguagesSectionProps {
|
||||||
languages: ExpertLanguage[];
|
languages: ExpertLanguage[];
|
||||||
onUpdate: () => Promise<void>;
|
onUpdate: () => Promise<void>;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGE_LEVELS = ['Muttersprache', 'Verhandlungssicher', 'Fließend', 'Gut'];
|
const LANGUAGE_LEVELS = ['Muttersprache', 'Verhandlungssicher', 'Fließend', 'Gut'];
|
||||||
|
|
||||||
export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) {
|
export function LanguagesSection({ languages, onUpdate, apiBase = '/expert-profile/me' }: LanguagesSectionProps) {
|
||||||
const [language, setLanguage] = useState('');
|
const [language, setLanguage] = useState('');
|
||||||
const [level, setLevel] = useState('');
|
const [level, setLevel] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -23,7 +24,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps)
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.post('/expert-profile/me/languages', {
|
await api.post(`${apiBase}/languages`, {
|
||||||
language: language.trim(),
|
language: language.trim(),
|
||||||
level,
|
level,
|
||||||
});
|
});
|
||||||
|
|
@ -41,7 +42,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps)
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.delete(`/expert-profile/me/languages/${id}`);
|
await api.delete(`${apiBase}/languages/${id}`);
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fehler beim Löschen');
|
setError('Fehler beim Löschen');
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface ProjectModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => Promise<void>;
|
onSave: () => Promise<void>;
|
||||||
project: ExpertProject | null;
|
project: ExpertProject | null;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
|
|
@ -327,7 +328,7 @@ function BulletEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalProps) {
|
export function ProjectModal({ isOpen, onClose, onSave, project, apiBase = '/expert-profile/me' }: ProjectModalProps) {
|
||||||
const [fromMonth, setFromMonth] = useState(1);
|
const [fromMonth, setFromMonth] = useState(1);
|
||||||
const [fromYear, setFromYear] = useState(currentYear);
|
const [fromYear, setFromYear] = useState(currentYear);
|
||||||
const [toMonth, setToMonth] = useState(1);
|
const [toMonth, setToMonth] = useState(1);
|
||||||
|
|
@ -389,9 +390,9 @@ export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalP
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (project) {
|
if (project) {
|
||||||
await api.patch(`/expert-profile/me/projects/${project.id}`, payload);
|
await api.patch(`${apiBase}/projects/${project.id}`, payload);
|
||||||
} else {
|
} else {
|
||||||
await api.post('/expert-profile/me/projects', payload);
|
await api.post(`${apiBase}/projects`, payload);
|
||||||
}
|
}
|
||||||
await onSave();
|
await onSave();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ function RichText({ text }: { text: string }) {
|
||||||
interface ProjectsSectionProps {
|
interface ProjectsSectionProps {
|
||||||
projects: ExpertProject[];
|
projects: ExpertProject[];
|
||||||
onUpdate: () => Promise<void>;
|
onUpdate: () => Promise<void>;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||||
|
|
@ -79,7 +80,7 @@ function formatPeriod(p: ExpertProject): string {
|
||||||
return from;
|
return from;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
|
export function ProjectsSection({ projects, onUpdate, apiBase = '/expert-profile/me' }: ProjectsSectionProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editProject, setEditProject] = useState<ExpertProject | null>(null);
|
const [editProject, setEditProject] = useState<ExpertProject | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -99,7 +100,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.delete(`/expert-profile/me/projects/${id}`);
|
await api.delete(`${apiBase}/projects/${id}`);
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fehler beim Löschen');
|
setError('Fehler beim Löschen');
|
||||||
|
|
@ -182,6 +183,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
onSave={handleModalSave}
|
onSave={handleModalSave}
|
||||||
project={editProject}
|
project={editProject}
|
||||||
|
apiBase={apiBase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import styles from '../ExpertProfileTab.module.css';
|
||||||
interface SkillsSectionProps {
|
interface SkillsSectionProps {
|
||||||
skills: string[];
|
skills: string[];
|
||||||
onUpdate: () => Promise<void>;
|
onUpdate: () => Promise<void>;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
|
export function SkillsSection({ skills, onUpdate, apiBase = '/expert-profile/me' }: SkillsSectionProps) {
|
||||||
const [newSkill, setNewSkill] = useState('');
|
const [newSkill, setNewSkill] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
@ -23,7 +24,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.patch('/expert-profile/me/skills', { skills: [...skills, skill] });
|
await api.patch(`${apiBase}/skills`, { skills: [...skills, skill] });
|
||||||
setNewSkill('');
|
setNewSkill('');
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -37,7 +38,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.patch('/expert-profile/me/skills', {
|
await api.patch(`${apiBase}/skills`, {
|
||||||
skills: skills.filter((s) => s !== skillToRemove),
|
skills: skills.filter((s) => s !== skillToRemove),
|
||||||
});
|
});
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||||
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
||||||
import { AdminSslPage } from '../admin/AdminSslPage';
|
import { AdminSslPage } from '../admin/AdminSslPage';
|
||||||
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
||||||
|
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
|
||||||
|
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
|
||||||
import { ProfilePage } from '../profile/ProfilePage';
|
import { ProfilePage } from '../profile/ProfilePage';
|
||||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||||
|
|
@ -91,7 +93,10 @@ export function App() {
|
||||||
<Route path="company" element={<AdminCompanyPage />} />
|
<Route path="company" element={<AdminCompanyPage />} />
|
||||||
<Route path="events" element={<AdminEventsPage />} />
|
<Route path="events" element={<AdminEventsPage />} />
|
||||||
<Route path="ssl" element={<AdminSslPage />} />
|
<Route path="ssl" element={<AdminSslPage />} />
|
||||||
|
<Route path="profile-access" element={<AdminProfileAccessPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
|
||||||
|
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue