From de4af77c5ccac4f529acdf84896450d9e80ca688 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sat, 14 Mar 2026 22:20:53 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20CRM=20Berechtigungsmodell=20=E2=80=94?= =?UTF-8?q?=20konfigurierbares=20Sichtbarkeitsmodell=20(OWN/TEAM/ALL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert pro-Entity Sichtbarkeitssteuerung für Companies, Contacts, Deals und Activities mit Rollen-basierter Zugriffskontrolle (ADMIN sieht alles, TEAM_LEAD mindestens Team-Sicht, READONLY nur Lesezugriff). - JWT Payload um tenantRole + department erweitert (Core + CRM) - Team-Members-Endpoint im Core Service (GET /users/team-members) - VisibilityModule mit Redis-Cache (CRM Service) - ReadonlyGuard als globaler Guard (CRM Service) - buildVisibilityFilter Utility für Prisma WHERE-Filterung - Admin-Einstellungsseite /admin/crm-settings (Frontend) Co-Authored-By: Claude Opus 4.6 --- .../decorators/current-user.decorator.ts | 2 + .../src/core/auth/auth.service.ts | 8 + .../src/core/users/users.controller.ts | 18 ++ .../src/core/users/users.service.ts | 25 +++ packages/crm-service/prisma/crm.schema.prisma | 25 +++ .../20260314_crm_visibility/migration.sql | 21 ++ .../src/activities/activities.controller.ts | 6 + .../src/activities/activities.module.ts | 2 + .../src/activities/activities.service.ts | 48 ++++- packages/crm-service/src/app.module.ts | 7 + .../src/auth/guards/readonly.guard.ts | 43 +++++ .../decorators/current-user.decorator.ts | 2 + .../common/utils/build-visibility-filter.ts | 76 ++++++++ .../src/companies/companies.controller.ts | 6 + .../src/companies/companies.module.ts | 3 +- .../src/companies/companies.service.ts | 39 +++- .../src/contacts/contacts.controller.ts | 4 +- .../src/contacts/contacts.module.ts | 3 +- .../src/contacts/contacts.service.ts | 39 +++- .../crm-service/src/deals/deals.controller.ts | 6 +- .../crm-service/src/deals/deals.module.ts | 3 +- .../crm-service/src/deals/deals.service.ts | 39 +++- .../src/visibility/dto/set-visibility.dto.ts | 13 ++ .../src/visibility/team-resolver.service.ts | 88 +++++++++ .../src/visibility/visibility.controller.ts | 68 +++++++ .../src/visibility/visibility.module.ts | 11 ++ .../src/visibility/visibility.service.ts | 93 +++++++++ .../src/admin/AdminCrmSettingsPage.module.css | 180 ++++++++++++++++++ .../src/admin/AdminCrmSettingsPage.tsx | 180 ++++++++++++++++++ packages/frontend/src/admin/AdminLayout.tsx | 1 + packages/frontend/src/shell/App.tsx | 2 + 31 files changed, 1044 insertions(+), 17 deletions(-) create mode 100644 packages/crm-service/prisma/migrations/20260314_crm_visibility/migration.sql create mode 100644 packages/crm-service/src/auth/guards/readonly.guard.ts create mode 100644 packages/crm-service/src/common/utils/build-visibility-filter.ts create mode 100644 packages/crm-service/src/visibility/dto/set-visibility.dto.ts create mode 100644 packages/crm-service/src/visibility/team-resolver.service.ts create mode 100644 packages/crm-service/src/visibility/visibility.controller.ts create mode 100644 packages/crm-service/src/visibility/visibility.module.ts create mode 100644 packages/crm-service/src/visibility/visibility.service.ts create mode 100644 packages/frontend/src/admin/AdminCrmSettingsPage.module.css create mode 100644 packages/frontend/src/admin/AdminCrmSettingsPage.tsx diff --git a/packages/core-service/src/common/decorators/current-user.decorator.ts b/packages/core-service/src/common/decorators/current-user.decorator.ts index b77a12c..e6f4b8a 100644 --- a/packages/core-service/src/common/decorators/current-user.decorator.ts +++ b/packages/core-service/src/common/decorators/current-user.decorator.ts @@ -7,6 +7,8 @@ export interface JwtPayload { role: string; tenantId?: string; tenantSlug?: string; + tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY + department?: string; // User-Abteilung (fuer TEAM-Visibility) jti: string; // Token-ID fuer Revocation iat: number; exp: number; diff --git a/packages/core-service/src/core/auth/auth.service.ts b/packages/core-service/src/core/auth/auth.service.ts index ded3b24..50ee6da 100644 --- a/packages/core-service/src/core/auth/auth.service.ts +++ b/packages/core-service/src/core/auth/auth.service.ts @@ -148,6 +148,8 @@ export class AuthService { role: user.role, tenantId: primaryMembership?.tenant.id, tenantSlug: primaryMembership?.tenant.slug, + tenantRole: primaryMembership?.tenantRole, + department: user.department ?? undefined, }); this.logger.log(`Login erfolgreich: ${user.email}`); @@ -197,6 +199,8 @@ export class AuthService { role: payload.role, tenantId: payload.tenantId, tenantSlug: payload.tenantSlug, + tenantRole: payload.tenantRole, + department: payload.department, }); } catch (error) { if (error instanceof UnauthorizedException) throw error; @@ -381,8 +385,10 @@ export class AuthService { firstName: string; lastName: string; role: string; + department?: string | null; twoFactorEnabled: boolean; tenantMemberships?: Array<{ + tenantRole: string; tenant: { id: string; slug: string }; }>; }; @@ -473,6 +479,8 @@ export class AuthService { role: user.role, tenantId: primaryMembership?.tenant.id, tenantSlug: primaryMembership?.tenant.slug, + tenantRole: primaryMembership?.tenantRole, + department: user.department ?? undefined, }); return { diff --git a/packages/core-service/src/core/users/users.controller.ts b/packages/core-service/src/core/users/users.controller.ts index 07b989c..ee087a9 100644 --- a/packages/core-service/src/core/users/users.controller.ts +++ b/packages/core-service/src/core/users/users.controller.ts @@ -69,6 +69,24 @@ export class UsersController { return { message: 'Passwort erfolgreich geändert' }; } + /** + * GET /api/v1/users/team-members + * User-IDs im gleichen Department abrufen (fuer TEAM-Visibility). + * Gibt nur IDs zurueck, keine sensiblen Daten. + */ + @Get('team-members') + @ApiOperation({ summary: 'Team-Mitglieder (gleiche Abteilung) abrufen' }) + async getTeamMembers(@CurrentUser() user: JwtPayload) { + if (!user.tenantId || !user.department) { + return { data: { userIds: [user.sub] } }; + } + const userIds = await this.usersService.findUserIdsByDepartment( + user.tenantId, + user.department, + ); + return { data: { userIds } }; + } + /** * GET /api/v1/users * Alle User auflisten (nur PLATFORM_ADMIN). diff --git a/packages/core-service/src/core/users/users.service.ts b/packages/core-service/src/core/users/users.service.ts index b4d2585..4e48324 100644 --- a/packages/core-service/src/core/users/users.service.ts +++ b/packages/core-service/src/core/users/users.service.ts @@ -309,4 +309,29 @@ export class UsersService { }, }; } + + /** + * User-IDs nach Abteilung und Tenant abrufen. + * Wird fuer TEAM-Visibility im CRM-Service genutzt. + */ + async findUserIdsByDepartment( + tenantId: string, + department: string, + ): Promise { + const users = await this.prisma.user.findMany({ + where: { + department, + isActive: true, + tenantMemberships: { + some: { + tenant: { id: tenantId }, + isActive: true, + }, + }, + }, + select: { id: true }, + }); + + return users.map((u) => u.id); + } } diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index 4e4e98d..64c0c96 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -890,3 +890,28 @@ model TradeEvent { @@map("trade_events") @@schema("app_crm") } + +// -------------------------------------------------------- +// Phase 2.5 — CRM Visibility Settings +// -------------------------------------------------------- +enum VisibilityLevel { + OWN + TEAM + ALL + + @@schema("app_crm") +} + +model CrmVisibilitySetting { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + entity String @db.VarChar(50) // COMPANY, CONTACT, DEAL, ACTIVITY + level VisibilityLevel @default(ALL) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([tenantId, entity]) + @@index([tenantId]) + @@map("crm_visibility_settings") + @@schema("app_crm") +} diff --git a/packages/crm-service/prisma/migrations/20260314_crm_visibility/migration.sql b/packages/crm-service/prisma/migrations/20260314_crm_visibility/migration.sql new file mode 100644 index 0000000..e4f4536 --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260314_crm_visibility/migration.sql @@ -0,0 +1,21 @@ +-- Migration: 20260314_crm_visibility +-- Beschreibung: CRM Visibility Settings (Sichtbarkeitssteuerung OWN/TEAM/ALL) + +-- CreateEnum +CREATE TYPE "app_crm"."VisibilityLevel" AS ENUM ('OWN', 'TEAM', 'ALL'); + +-- CreateTable +CREATE TABLE "app_crm"."crm_visibility_settings" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "entity" VARCHAR(50) NOT NULL, + "level" "app_crm"."VisibilityLevel" NOT NULL DEFAULT 'ALL', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "crm_visibility_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "crm_visibility_settings_tenant_id_entity_key" ON "app_crm"."crm_visibility_settings"("tenant_id", "entity"); +CREATE INDEX "crm_visibility_settings_tenant_id_idx" ON "app_crm"."crm_visibility_settings"("tenant_id"); diff --git a/packages/crm-service/src/activities/activities.controller.ts b/packages/crm-service/src/activities/activities.controller.ts index d22d470..f6eade7 100644 --- a/packages/crm-service/src/activities/activities.controller.ts +++ b/packages/crm-service/src/activities/activities.controller.ts @@ -7,11 +7,13 @@ import { Body, Param, Query, + Req, ParseUUIDPipe, HttpCode, HttpStatus, UseGuards, } from '@nestjs/common'; +import { Request } from 'express'; import { ApiTags, ApiOperation, @@ -56,10 +58,14 @@ export class ActivitiesController { async findAll( @CurrentUser() user: JwtPayload, @Query() query: QueryActivitiesDto, + @Req() req: Request, ) { + const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', ''); const result = await this.activitiesService.findAll( user.tenantId!, query, + user, + bearerToken, ); return paginatedResponse( result.data, diff --git a/packages/crm-service/src/activities/activities.module.ts b/packages/crm-service/src/activities/activities.module.ts index 180a457..e4138f2 100644 --- a/packages/crm-service/src/activities/activities.module.ts +++ b/packages/crm-service/src/activities/activities.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ActivitiesController } from './activities.controller'; import { ActivitiesService } from './activities.service'; +import { VisibilityModule } from '../visibility/visibility.module'; @Module({ + imports: [VisibilityModule], controllers: [ActivitiesController], providers: [ActivitiesService], exports: [ActivitiesService], diff --git a/packages/crm-service/src/activities/activities.service.ts b/packages/crm-service/src/activities/activities.service.ts index 5c6ca41..70a4632 100644 --- a/packages/crm-service/src/activities/activities.service.ts +++ b/packages/crm-service/src/activities/activities.service.ts @@ -7,11 +7,18 @@ import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateActivityDto } from './dto/create-activity.dto'; import { UpdateActivityDto } from './dto/update-activity.dto'; import { QueryActivitiesDto } from './dto/query-activities.dto'; -import { Prisma } from '.prisma/crm-client'; +import { VisibilityService } from '../visibility/visibility.service'; +import { TeamResolverService } from '../visibility/team-resolver.service'; +import { JwtPayload } from '../common/decorators/current-user.decorator'; +import { Prisma, VisibilityLevel } from '.prisma/crm-client'; @Injectable() export class ActivitiesService { - constructor(private readonly prisma: CrmPrismaService) {} + constructor( + private readonly prisma: CrmPrismaService, + private readonly visibilityService: VisibilityService, + private readonly teamResolver: TeamResolverService, + ) {} async create(tenantId: string, userId: string, dto: CreateActivityDto) { // Mindestens contactId oder companyId muss gesetzt sein @@ -64,12 +71,47 @@ export class ActivitiesService { }); } - async findAll(tenantId: string, query: QueryActivitiesDto) { + async findAll( + tenantId: string, + query: QueryActivitiesDto, + user?: JwtPayload, + bearerToken?: string, + ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const where: Prisma.ActivityWhereInput = { tenantId }; + // Visibility-Filter: Activities filtern ueber createdBy (kein Owner-Table) + if (user) { + const level = await this.visibilityService.getLevel(tenantId, 'ACTIVITY'); + const isAdmin = + user.role === 'PLATFORM_ADMIN' || + user.role === 'TENANT_ADMIN' || + user.tenantRole === 'ADMIN'; + + if (!isAdmin && level !== VisibilityLevel.ALL) { + let effectiveLevel = level; + if (user.tenantRole === 'TEAM_LEAD' && level === VisibilityLevel.OWN) { + effectiveLevel = VisibilityLevel.TEAM; + } + + if (effectiveLevel === VisibilityLevel.OWN) { + where.createdBy = user.sub; + } else if (effectiveLevel === VisibilityLevel.TEAM) { + let teamIds = [user.sub]; + if (bearerToken) { + teamIds = await this.teamResolver.getTeamMemberIds( + user.sub, + user.department, + bearerToken, + ); + } + where.createdBy = { in: teamIds }; + } + } + } + // Aggregierter Company-Feed: direkte + verknuepfte Kontakt-Aktivitaeten if (query.companyId && query.includeContacts) { const contactIds = await this.prisma.contact diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index b6ae826..afcb122 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -7,6 +7,7 @@ import { CrmPrismaModule } from './prisma/crm-prisma.module'; import { RedisModule } from './redis/redis.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { ReadonlyGuard } from './auth/guards/readonly.guard'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { HealthModule } from './health/health.module'; import { ContactsModule } from './contacts/contacts.module'; @@ -27,6 +28,7 @@ import { EnrichmentModule } from './enrichment/enrichment.module'; import { ContractsModule } from './contracts/contracts.module'; import { GraphModule } from './graph/graph.module'; import { DealTypesModule } from './deal-types/deal-types.module'; +import { VisibilityModule } from './visibility/visibility.module'; @Module({ imports: [ @@ -57,12 +59,17 @@ import { DealTypesModule } from './deal-types/deal-types.module'; ContractsModule, GraphModule, DealTypesModule, + VisibilityModule, ], providers: [ { provide: APP_GUARD, useClass: JwtAuthGuard, }, + { + provide: APP_GUARD, + useClass: ReadonlyGuard, + }, { provide: APP_FILTER, useClass: GlobalExceptionFilter, diff --git a/packages/crm-service/src/auth/guards/readonly.guard.ts b/packages/crm-service/src/auth/guards/readonly.guard.ts new file mode 100644 index 0000000..5cc1d0c --- /dev/null +++ b/packages/crm-service/src/auth/guards/readonly.guard.ts @@ -0,0 +1,43 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtPayload } from '../../common/decorators/current-user.decorator'; + +/** + * ReadonlyGuard: Blockiert alle schreibenden Requests (POST, PATCH, PUT, DELETE) + * fuer User mit tenantRole === 'READONLY'. + * + * GET-Requests werden immer durchgelassen. + */ +@Injectable() +export class ReadonlyGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const method = request.method as string; + + // GET-Requests sind immer erlaubt + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { + return true; + } + + const user = request.user as JwtPayload | undefined; + if (!user) { + return true; // Kein User → andere Guards handeln das + } + + // READONLY-User duerfen nicht schreiben + if (user.tenantRole === 'READONLY') { + throw new ForbiddenException( + 'Ihr Benutzerkonto hat nur Leserechte. Schreiboperationen sind nicht erlaubt.', + ); + } + + return true; + } +} diff --git a/packages/crm-service/src/common/decorators/current-user.decorator.ts b/packages/crm-service/src/common/decorators/current-user.decorator.ts index fe2fc9f..ee0d53c 100644 --- a/packages/crm-service/src/common/decorators/current-user.decorator.ts +++ b/packages/crm-service/src/common/decorators/current-user.decorator.ts @@ -7,6 +7,8 @@ export interface JwtPayload { role: string; tenantId?: string; tenantSlug?: string; + tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY + department?: string; // User-Abteilung (fuer TEAM-Visibility) jti: string; iat: number; exp: number; diff --git a/packages/crm-service/src/common/utils/build-visibility-filter.ts b/packages/crm-service/src/common/utils/build-visibility-filter.ts new file mode 100644 index 0000000..1a1ccde --- /dev/null +++ b/packages/crm-service/src/common/utils/build-visibility-filter.ts @@ -0,0 +1,76 @@ +import { VisibilityLevel } from '.prisma/crm-client'; +import { JwtPayload } from '../decorators/current-user.decorator'; + +/** + * Erzeugt einen Prisma WHERE-Filter basierend auf dem Sichtbarkeitslevel. + * + * - ALL: Kein zusaetzlicher Filter (alle Tenant-Daten sichtbar) + * - OWN: Nur Datensaetze, bei denen der User Owner/Member ist + * - TEAM: Datensaetze aller Team-Mitglieder (gleiche Abteilung) + * + * ADMIN und PLATFORM_ADMIN sehen immer alles. + * TEAM_LEAD sieht mindestens TEAM-Level. + */ +export function buildVisibilityFilter(params: { + user: JwtPayload; + level: VisibilityLevel; + ownerRelation: string; // z.B. 'companyOwners', 'contactOwners', 'dealOwners' + teamMemberIds?: string[]; // User-IDs im gleichen Department +}): Record { + const { user, level, ownerRelation, teamMemberIds } = params; + const tenantId = user.tenantId; + + // Admins sehen immer alles + if ( + user.role === 'PLATFORM_ADMIN' || + user.role === 'TENANT_ADMIN' || + user.tenantRole === 'ADMIN' + ) { + return { tenantId }; + } + + // Effektives Level bestimmen (TEAM_LEAD sieht mindestens TEAM) + let effectiveLevel = level; + if (user.tenantRole === 'TEAM_LEAD' && level === VisibilityLevel.OWN) { + effectiveLevel = VisibilityLevel.TEAM; + } + + switch (effectiveLevel) { + case VisibilityLevel.ALL: + return { tenantId }; + + case VisibilityLevel.OWN: + return { + tenantId, + [ownerRelation]: { + some: { userId: user.sub }, + }, + }; + + case VisibilityLevel.TEAM: { + // Wenn Team-Member-IDs vorhanden: nach diesen filtern + if (teamMemberIds && teamMemberIds.length > 0) { + // Eigene ID immer einschliessen + const allIds = teamMemberIds.includes(user.sub) + ? teamMemberIds + : [...teamMemberIds, user.sub]; + return { + tenantId, + [ownerRelation]: { + some: { userId: { in: allIds } }, + }, + }; + } + // Fallback: nur eigene Daten (wenn kein Department/Team aufgeloest werden konnte) + return { + tenantId, + [ownerRelation]: { + some: { userId: user.sub }, + }, + }; + } + + default: + return { tenantId }; + } +} diff --git a/packages/crm-service/src/companies/companies.controller.ts b/packages/crm-service/src/companies/companies.controller.ts index bb2ae36..40c72bc 100644 --- a/packages/crm-service/src/companies/companies.controller.ts +++ b/packages/crm-service/src/companies/companies.controller.ts @@ -7,11 +7,13 @@ import { Body, Param, Query, + Req, ParseUUIDPipe, HttpCode, HttpStatus, UseGuards, } from '@nestjs/common'; +import { Request } from 'express'; import { ApiTags, ApiOperation, @@ -68,10 +70,14 @@ export class CompaniesController { async findAll( @CurrentUser() user: JwtPayload, @Query() query: QueryCompaniesDto, + @Req() req: Request, ) { + const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', ''); const result = await this.companiesService.findAll( user.tenantId!, query, + user, + bearerToken, ); return paginatedResponse( result.data, diff --git a/packages/crm-service/src/companies/companies.module.ts b/packages/crm-service/src/companies/companies.module.ts index 01c6939..6296ea5 100644 --- a/packages/crm-service/src/companies/companies.module.ts +++ b/packages/crm-service/src/companies/companies.module.ts @@ -5,9 +5,10 @@ import { CrmPrismaModule } from '../prisma/crm-prisma.module'; import { LexwareModule } from '../lexware/lexware.module'; import { OwnersModule } from '../owners/owners.module'; import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; +import { VisibilityModule } from '../visibility/visibility.module'; @Module({ - imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule], + imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule, VisibilityModule], controllers: [CompaniesController], providers: [CompaniesService], exports: [CompaniesService], diff --git a/packages/crm-service/src/companies/companies.service.ts b/packages/crm-service/src/companies/companies.service.ts index 239ce4b..981bd0f 100644 --- a/packages/crm-service/src/companies/companies.service.ts +++ b/packages/crm-service/src/companies/companies.service.ts @@ -6,7 +6,11 @@ import { QueryCompaniesDto } from './dto/query-companies.dto'; import { LexwareContactsService } from '../lexware/lexware-contacts.service'; import { CustomFieldsService } from '../custom-fields/custom-fields.service'; import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; -import { Prisma } from '.prisma/crm-client'; +import { VisibilityService } from '../visibility/visibility.service'; +import { TeamResolverService } from '../visibility/team-resolver.service'; +import { buildVisibilityFilter } from '../common/utils/build-visibility-filter'; +import { JwtPayload } from '../common/decorators/current-user.decorator'; +import { Prisma, VisibilityLevel } from '.prisma/crm-client'; import { EntityStatus } from '../common/dto/contact-info.dto'; @Injectable() @@ -17,6 +21,8 @@ export class CompaniesService { private readonly prisma: CrmPrismaService, private readonly lexwareContacts: LexwareContactsService, private readonly customFieldsService: CustomFieldsService, + private readonly visibilityService: VisibilityService, + private readonly teamResolver: TeamResolverService, ) {} async create(tenantId: string, userId: string, dto: CreateCompanyDto) { @@ -108,11 +114,38 @@ export class CompaniesService { }); } - async findAll(tenantId: string, query: QueryCompaniesDto) { + async findAll( + tenantId: string, + query: QueryCompaniesDto, + user?: JwtPayload, + bearerToken?: string, + ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; - const where: Prisma.CompanyWhereInput = { tenantId }; + // Visibility-Filter aufbauen + let baseWhere: Record = { tenantId }; + if (user) { + const level = await this.visibilityService.getLevel(tenantId, 'COMPANY'); + if (level !== VisibilityLevel.ALL) { + let teamMemberIds: string[] | undefined; + if (level === VisibilityLevel.TEAM && bearerToken) { + teamMemberIds = await this.teamResolver.getTeamMemberIds( + user.sub, + user.department, + bearerToken, + ); + } + baseWhere = buildVisibilityFilter({ + user, + level, + ownerRelation: 'owners', + teamMemberIds, + }); + } + } + + const where: Prisma.CompanyWhereInput = baseWhere as Prisma.CompanyWhereInput; if (query.industry) { where.industry = { contains: query.industry, mode: 'insensitive' }; diff --git a/packages/crm-service/src/contacts/contacts.controller.ts b/packages/crm-service/src/contacts/contacts.controller.ts index 10d62eb..4ca39d6 100644 --- a/packages/crm-service/src/contacts/contacts.controller.ts +++ b/packages/crm-service/src/contacts/contacts.controller.ts @@ -66,8 +66,10 @@ export class ContactsController { async findAll( @CurrentUser() user: JwtPayload, @Query() query: QueryContactsDto, + @Req() req: Request, ) { - const result = await this.contactsService.findAll(user.tenantId!, query); + const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', ''); + const result = await this.contactsService.findAll(user.tenantId!, query, user, bearerToken); return paginatedResponse( result.data, result.total, diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts index f79594c..42519c3 100644 --- a/packages/crm-service/src/contacts/contacts.module.ts +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -5,9 +5,10 @@ import { LexwareModule } from '../lexware/lexware.module'; import { OwnersModule } from '../owners/owners.module'; import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; import { GraphModule } from '../graph/graph.module'; +import { VisibilityModule } from '../visibility/visibility.module'; @Module({ - imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule], + imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule, VisibilityModule], controllers: [ContactsController], providers: [ContactsService], exports: [ContactsService], diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts index dd5d1fa..43e85a2 100644 --- a/packages/crm-service/src/contacts/contacts.service.ts +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -7,7 +7,11 @@ import { LexwareContactsService } from '../lexware/lexware-contacts.service'; import { CrmEventPublisher } from '../events/crm-event-publisher.service'; import { CustomFieldsService } from '../custom-fields/custom-fields.service'; import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; -import { Prisma } from '.prisma/crm-client'; +import { VisibilityService } from '../visibility/visibility.service'; +import { TeamResolverService } from '../visibility/team-resolver.service'; +import { buildVisibilityFilter } from '../common/utils/build-visibility-filter'; +import { JwtPayload } from '../common/decorators/current-user.decorator'; +import { Prisma, VisibilityLevel } from '.prisma/crm-client'; import { EntityStatus } from '../common/dto/contact-info.dto'; @Injectable() @@ -19,6 +23,8 @@ export class ContactsService { private readonly lexwareContacts: LexwareContactsService, private readonly eventPublisher: CrmEventPublisher, private readonly customFieldsService: CustomFieldsService, + private readonly visibilityService: VisibilityService, + private readonly teamResolver: TeamResolverService, ) {} async create(tenantId: string, userId: string, dto: CreateContactDto) { @@ -116,11 +122,38 @@ export class ContactsService { return contact; } - async findAll(tenantId: string, query: QueryContactsDto) { + async findAll( + tenantId: string, + query: QueryContactsDto, + user?: JwtPayload, + bearerToken?: string, + ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; - const where: Prisma.ContactWhereInput = { tenantId }; + // Visibility-Filter aufbauen + let baseWhere: Record = { tenantId }; + if (user) { + const level = await this.visibilityService.getLevel(tenantId, 'CONTACT'); + if (level !== VisibilityLevel.ALL) { + let teamMemberIds: string[] | undefined; + if (level === VisibilityLevel.TEAM && bearerToken) { + teamMemberIds = await this.teamResolver.getTeamMemberIds( + user.sub, + user.department, + bearerToken, + ); + } + baseWhere = buildVisibilityFilter({ + user, + level, + ownerRelation: 'owners', + teamMemberIds, + }); + } + } + + const where: Prisma.ContactWhereInput = baseWhere as Prisma.ContactWhereInput; if (query.type) { where.type = query.type; diff --git a/packages/crm-service/src/deals/deals.controller.ts b/packages/crm-service/src/deals/deals.controller.ts index ca0bf29..62044eb 100644 --- a/packages/crm-service/src/deals/deals.controller.ts +++ b/packages/crm-service/src/deals/deals.controller.ts @@ -7,11 +7,13 @@ import { Body, Param, Query, + Req, ParseUUIDPipe, HttpCode, HttpStatus, UseGuards, } from '@nestjs/common'; +import { Request } from 'express'; import { ApiTags, ApiOperation, @@ -62,8 +64,10 @@ export class DealsController { async findAll( @CurrentUser() user: JwtPayload, @Query() query: QueryDealsDto, + @Req() req: Request, ) { - const result = await this.dealsService.findAll(user.tenantId!, query); + const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', ''); + const result = await this.dealsService.findAll(user.tenantId!, query, user, bearerToken); return paginatedResponse( result.data, result.total, diff --git a/packages/crm-service/src/deals/deals.module.ts b/packages/crm-service/src/deals/deals.module.ts index 930868b..fcc6dd8 100644 --- a/packages/crm-service/src/deals/deals.module.ts +++ b/packages/crm-service/src/deals/deals.module.ts @@ -3,9 +3,10 @@ import { DealsController } from './deals.controller'; import { DealsService } from './deals.service'; import { OwnersModule } from '../owners/owners.module'; import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; +import { VisibilityModule } from '../visibility/visibility.module'; @Module({ - imports: [OwnersModule, CustomFieldsModule], + imports: [OwnersModule, CustomFieldsModule, VisibilityModule], controllers: [DealsController], providers: [DealsService], exports: [DealsService], diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index ed0b447..83ec906 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -11,7 +11,11 @@ import { ForecastQueryDto, ForecastPeriod } from './dto/forecast-query.dto'; import { CrmEventPublisher } from '../events/crm-event-publisher.service'; import { CustomFieldsService } from '../custom-fields/custom-fields.service'; import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; -import { Prisma } from '.prisma/crm-client'; +import { VisibilityService } from '../visibility/visibility.service'; +import { TeamResolverService } from '../visibility/team-resolver.service'; +import { buildVisibilityFilter } from '../common/utils/build-visibility-filter'; +import { JwtPayload } from '../common/decorators/current-user.decorator'; +import { Prisma, VisibilityLevel } from '.prisma/crm-client'; @Injectable() export class DealsService { @@ -19,6 +23,8 @@ export class DealsService { private readonly prisma: CrmPrismaService, private readonly eventPublisher: CrmEventPublisher, private readonly customFieldsService: CustomFieldsService, + private readonly visibilityService: VisibilityService, + private readonly teamResolver: TeamResolverService, ) {} async create(tenantId: string, userId: string, dto: CreateDealDto) { @@ -125,11 +131,38 @@ export class DealsService { return deal; } - async findAll(tenantId: string, query: QueryDealsDto) { + async findAll( + tenantId: string, + query: QueryDealsDto, + user?: JwtPayload, + bearerToken?: string, + ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; - const where: Prisma.DealWhereInput = { tenantId }; + // Visibility-Filter aufbauen + let baseWhere: Record = { tenantId }; + if (user) { + const level = await this.visibilityService.getLevel(tenantId, 'DEAL'); + if (level !== VisibilityLevel.ALL) { + let teamMemberIds: string[] | undefined; + if (level === VisibilityLevel.TEAM && bearerToken) { + teamMemberIds = await this.teamResolver.getTeamMemberIds( + user.sub, + user.department, + bearerToken, + ); + } + baseWhere = buildVisibilityFilter({ + user, + level, + ownerRelation: 'owners', + teamMemberIds, + }); + } + } + + const where: Prisma.DealWhereInput = baseWhere as Prisma.DealWhereInput; if (query.pipelineId) { where.pipelineId = query.pipelineId; diff --git a/packages/crm-service/src/visibility/dto/set-visibility.dto.ts b/packages/crm-service/src/visibility/dto/set-visibility.dto.ts new file mode 100644 index 0000000..309f4c2 --- /dev/null +++ b/packages/crm-service/src/visibility/dto/set-visibility.dto.ts @@ -0,0 +1,13 @@ +import { IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { VisibilityLevel } from '.prisma/crm-client'; + +export class SetVisibilityDto { + @ApiProperty({ + enum: VisibilityLevel, + description: 'Sichtbarkeitslevel: OWN, TEAM oder ALL', + example: 'ALL', + }) + @IsEnum(VisibilityLevel) + level!: VisibilityLevel; +} diff --git a/packages/crm-service/src/visibility/team-resolver.service.ts b/packages/crm-service/src/visibility/team-resolver.service.ts new file mode 100644 index 0000000..52be70f --- /dev/null +++ b/packages/crm-service/src/visibility/team-resolver.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../redis/redis.service'; + +const CACHE_PREFIX = 'team_members'; +const CACHE_TTL = 300; // 5 Minuten + +/** + * Loest Team-Mitglieder (User-IDs im gleichen Department) auf. + * Ruft den Core-Service-Endpoint /api/v1/users/team-members auf + * und cached das Ergebnis in Redis. + */ +@Injectable() +export class TeamResolverService { + private readonly logger = new Logger(TeamResolverService.name); + private readonly coreServiceUrl: string; + + constructor( + private readonly redis: RedisService, + private readonly config: ConfigService, + ) { + this.coreServiceUrl = this.config.get( + 'CORE_SERVICE_URL', + 'http://core:3000', + ); + } + + /** + * Team-Member-IDs fuer einen User abrufen (cached). + * Nutzt den Bearer-Token des anfragenden Users fuer den Core-API-Call. + */ + async getTeamMemberIds( + userId: string, + department: string | undefined, + bearerToken: string, + ): Promise { + if (!department) { + return [userId]; + } + + const cacheKey = `${CACHE_PREFIX}:${userId}`; + + // Cache pruefen + const cached = await this.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as string[]; + } catch { + // Cache korrupt, neu laden + } + } + + // Core-Service aufrufen + try { + const response = await fetch( + `${this.coreServiceUrl}/api/v1/users/team-members`, + { + headers: { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + this.logger.warn( + `Core-Service team-members Aufruf fehlgeschlagen: ${response.status}`, + ); + return [userId]; + } + + const body = (await response.json()) as { + data: { userIds: string[] }; + }; + const userIds = body.data.userIds; + + // In Cache schreiben + await this.redis.set(cacheKey, JSON.stringify(userIds), CACHE_TTL); + + return userIds; + } catch (error) { + this.logger.warn( + `Core-Service team-members Aufruf fehlgeschlagen: ${error}`, + ); + return [userId]; + } + } +} diff --git a/packages/crm-service/src/visibility/visibility.controller.ts b/packages/crm-service/src/visibility/visibility.controller.ts new file mode 100644 index 0000000..100c91b --- /dev/null +++ b/packages/crm-service/src/visibility/visibility.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Put, + Param, + Body, + UseGuards, + ForbiddenException, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../common/decorators/current-user.decorator'; +import { VisibilityService } from './visibility.service'; +import { SetVisibilityDto } from './dto/set-visibility.dto'; + +const VALID_ENTITIES = ['COMPANY', 'CONTACT', 'DEAL', 'ACTIVITY']; + +@ApiTags('Visibility Settings') +@ApiBearerAuth() +@UseGuards(TenantGuard) +@Controller('visibility-settings') +export class VisibilityController { + constructor(private readonly visibilityService: VisibilityService) {} + + /** + * Prueft ob der User Tenant-Admin ist (tenantRole oder Platform-Admin). + */ + private assertTenantAdmin(user: JwtPayload): void { + const isAdmin = + user.role === 'PLATFORM_ADMIN' || + user.role === 'TENANT_ADMIN' || + user.tenantRole === 'ADMIN'; + if (!isAdmin) { + throw new ForbiddenException( + 'Nur Tenant-Administratoren koennen Sichtbarkeitseinstellungen aendern.', + ); + } + } + + @Get() + @ApiOperation({ summary: 'Alle Sichtbarkeitseinstellungen abrufen' }) + async getAll(@CurrentUser() user: JwtPayload) { + this.assertTenantAdmin(user); + const levels = await this.visibilityService.getAllLevels(user.tenantId!); + return { data: levels }; + } + + @Put(':entity') + @ApiOperation({ summary: 'Sichtbarkeitslevel fuer eine Entity setzen' }) + async setLevel( + @CurrentUser() user: JwtPayload, + @Param('entity') entity: string, + @Body() dto: SetVisibilityDto, + ) { + this.assertTenantAdmin(user); + + const entityUpper = entity.toUpperCase(); + if (!VALID_ENTITIES.includes(entityUpper)) { + throw new ForbiddenException( + `Ungueltige Entity: ${entity}. Erlaubt: ${VALID_ENTITIES.join(', ')}`, + ); + } + + await this.visibilityService.setLevel(user.tenantId!, entityUpper, dto.level); + return { data: { entity: entityUpper, level: dto.level } }; + } +} diff --git a/packages/crm-service/src/visibility/visibility.module.ts b/packages/crm-service/src/visibility/visibility.module.ts new file mode 100644 index 0000000..493a5de --- /dev/null +++ b/packages/crm-service/src/visibility/visibility.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { VisibilityService } from './visibility.service'; +import { VisibilityController } from './visibility.controller'; +import { TeamResolverService } from './team-resolver.service'; + +@Module({ + controllers: [VisibilityController], + providers: [VisibilityService, TeamResolverService], + exports: [VisibilityService, TeamResolverService], +}) +export class VisibilityModule {} diff --git a/packages/crm-service/src/visibility/visibility.service.ts b/packages/crm-service/src/visibility/visibility.service.ts new file mode 100644 index 0000000..80fe798 --- /dev/null +++ b/packages/crm-service/src/visibility/visibility.service.ts @@ -0,0 +1,93 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { RedisService } from '../redis/redis.service'; +import { VisibilityLevel } from '.prisma/crm-client'; + +const CACHE_PREFIX = 'crm_visibility'; +const CACHE_TTL = 300; // 5 Minuten + +@Injectable() +export class VisibilityService { + private readonly logger = new Logger(VisibilityService.name); + + constructor( + private readonly prisma: CrmPrismaService, + private readonly redis: RedisService, + ) {} + + /** + * Sichtbarkeitslevel fuer eine Entity abrufen (mit Redis-Cache). + * Default: ALL (bestehendes Verhalten bleibt erhalten). + */ + async getLevel( + tenantId: string, + entity: string, + ): Promise { + const cacheKey = `${CACHE_PREFIX}:${tenantId}:${entity}`; + + // Cache pruefen + const cached = await this.redis.get(cacheKey); + if (cached) { + return cached as VisibilityLevel; + } + + // Aus DB laden + const setting = await this.prisma.crmVisibilitySetting.findUnique({ + where: { tenantId_entity: { tenantId, entity } }, + }); + + const level = setting?.level ?? VisibilityLevel.ALL; + + // In Cache schreiben + await this.redis.set(cacheKey, level, CACHE_TTL); + + return level; + } + + /** + * Sichtbarkeitslevel fuer eine Entity setzen. + */ + async setLevel( + tenantId: string, + entity: string, + level: VisibilityLevel, + ): Promise { + await this.prisma.crmVisibilitySetting.upsert({ + where: { tenantId_entity: { tenantId, entity } }, + update: { level }, + create: { tenantId, entity, level }, + }); + + // Cache invalidieren + const cacheKey = `${CACHE_PREFIX}:${tenantId}:${entity}`; + await this.redis.del(cacheKey); + + this.logger.log( + `Visibility fuer ${entity} auf ${level} gesetzt (Tenant: ${tenantId})`, + ); + } + + /** + * Alle Sichtbarkeitslevels fuer einen Tenant abrufen. + */ + async getAllLevels( + tenantId: string, + ): Promise> { + const settings = await this.prisma.crmVisibilitySetting.findMany({ + where: { tenantId }, + }); + + const result: Record = { + COMPANY: VisibilityLevel.ALL, + CONTACT: VisibilityLevel.ALL, + DEAL: VisibilityLevel.ALL, + ACTIVITY: VisibilityLevel.ALL, + }; + + for (const s of settings) { + result[s.entity] = s.level; + } + + return result; + } +} diff --git a/packages/frontend/src/admin/AdminCrmSettingsPage.module.css b/packages/frontend/src/admin/AdminCrmSettingsPage.module.css new file mode 100644 index 0000000..a2bcc15 --- /dev/null +++ b/packages/frontend/src/admin/AdminCrmSettingsPage.module.css @@ -0,0 +1,180 @@ +.container { + max-width: 800px; +} + +.header { + margin-bottom: 24px; +} + +.header h2 { + font-size: 1.25rem; + font-weight: 600; + color: #111827; + margin: 0 0 8px 0; +} + +.subtitle { + font-size: 0.875rem; + color: #6b7280; + margin: 0; + line-height: 1.5; +} + +.card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table thead { + background: #f9fafb; +} + +.table th { + padding: 12px 16px; + text-align: left; + font-size: 0.8125rem; + font-weight: 600; + color: #374151; + border-bottom: 1px solid #e5e7eb; +} + +.table td { + padding: 16px; + border-bottom: 1px solid #f3f4f6; +} + +.table tr:last-child td { + border-bottom: none; +} + +.entityCol { + width: 180px; +} + +.levelCol { + width: auto; +} + +.levelHeader { + display: flex; + flex-direction: column; + gap: 2px; +} + +.levelDesc { + font-weight: 400; + font-size: 0.6875rem; + color: #9ca3af; + line-height: 1.3; +} + +.entityLabel { + font-weight: 500; + color: #111827; + font-size: 0.875rem; +} + +.radioCell { + text-align: center; +} + +.radioLabel { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.radio { + width: 16px; + height: 16px; + accent-color: #1a56db; + cursor: pointer; +} + +.radioText { + font-size: 0.8125rem; + color: #374151; +} + +.info { + display: flex; + gap: 12px; + margin-top: 20px; + padding: 16px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + font-size: 0.8125rem; + color: #1e40af; + line-height: 1.5; +} + +.info svg { + flex-shrink: 0; + margin-top: 2px; + color: #3b82f6; +} + +.infoList { + margin: 6px 0 0 0; + padding-left: 18px; +} + +.infoList li { + margin-bottom: 2px; +} + +.actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 24px; +} + +.saveButton { + padding: 10px 24px; + background: #1a56db; + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.saveButton:hover:not(:disabled) { + background: #1e429f; +} + +.saveButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.successMsg { + font-size: 0.8125rem; + color: #059669; + font-weight: 500; +} + +.errorMsg { + font-size: 0.8125rem; + color: #dc2626; + font-weight: 500; +} + +.loading { + padding: 40px; + text-align: center; + color: #6b7280; + font-size: 0.875rem; +} diff --git a/packages/frontend/src/admin/AdminCrmSettingsPage.tsx b/packages/frontend/src/admin/AdminCrmSettingsPage.tsx new file mode 100644 index 0000000..69bec39 --- /dev/null +++ b/packages/frontend/src/admin/AdminCrmSettingsPage.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; +import styles from './AdminCrmSettingsPage.module.css'; + +type VisibilityLevel = 'OWN' | 'TEAM' | 'ALL'; + +interface VisibilitySettings { + COMPANY: VisibilityLevel; + CONTACT: VisibilityLevel; + DEAL: VisibilityLevel; + ACTIVITY: VisibilityLevel; +} + +const ENTITIES = [ + { key: 'COMPANY' as const, label: 'Unternehmen' }, + { key: 'CONTACT' as const, label: 'Kontakte' }, + { key: 'DEAL' as const, label: 'Vorgänge' }, + { key: 'ACTIVITY' as const, label: 'Aktivitäten' }, +]; + +const LEVELS: { value: VisibilityLevel; label: string; description: string }[] = [ + { + value: 'ALL', + label: 'Alle', + description: 'Alle Mitarbeiter sehen alle Datensätze im Mandanten', + }, + { + value: 'TEAM', + label: 'Team', + description: 'Mitarbeiter sehen nur Datensätze ihres Teams (gleiche Abteilung)', + }, + { + value: 'OWN', + label: 'Eigene', + description: 'Mitarbeiter sehen nur Datensätze, bei denen sie als Besitzer eingetragen sind', + }, +]; + +export function AdminCrmSettingsPage() { + const queryClient = useQueryClient(); + const [settings, setSettings] = useState({ + COMPANY: 'ALL', + CONTACT: 'ALL', + DEAL: 'ALL', + ACTIVITY: 'ALL', + }); + const [hasChanges, setHasChanges] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + const { data, isLoading } = useQuery<{ data: VisibilitySettings }>({ + queryKey: ['crm', 'visibility-settings'], + queryFn: () => api.get('/crm/visibility-settings').then((r) => r.data), + }); + + useEffect(() => { + if (data?.data) { + setSettings(data.data); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: async (newSettings: VisibilitySettings) => { + // Parallel alle Entities speichern + await Promise.all( + ENTITIES.map((e) => + api.put(`/crm/visibility-settings/${e.key}`, { + level: newSettings[e.key], + }), + ), + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crm', 'visibility-settings'] }); + setHasChanges(false); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 3000); + }, + }); + + const handleChange = (entity: keyof VisibilitySettings, level: VisibilityLevel) => { + setSettings((prev) => ({ ...prev, [entity]: level })); + setHasChanges(true); + setSaveSuccess(false); + }; + + const handleSave = () => { + saveMutation.mutate(settings); + }; + + if (isLoading) { + return
Lade Einstellungen...
; + } + + return ( +
+
+

CRM Sichtbarkeitseinstellungen

+

+ Legen Sie fest, welche CRM-Datensätze für Ihre Mitarbeiter sichtbar sind. + Administratoren sehen immer alle Daten unabhängig von dieser Einstellung. +

+
+ +
+ + + + + {LEVELS.map((l) => ( + + ))} + + + + {ENTITIES.map((entity) => ( + + + {LEVELS.map((level) => ( + + ))} + + ))} + +
Bereich +
+ {l.label} + {l.description} +
+
{entity.label} + +
+
+ +
+ + + + + +
+ Hinweis: Rollen-basierte Ausnahmen: +
    +
  • Admin sieht immer alle Datensätze
  • +
  • Team-Lead sieht mindestens Team-Sicht (auch wenn "Eigene" eingestellt ist)
  • +
  • Nur-Lesen kann Daten ansehen, aber nicht bearbeiten
  • +
+
+
+ +
+ + {saveSuccess && ( + Einstellungen gespeichert + )} + {saveMutation.isError && ( + + Fehler beim Speichern. Bitte versuchen Sie es erneut. + + )} +
+
+ ); +} diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index ab1d938..9319660 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -10,6 +10,7 @@ const tabs = [ { to: '/admin/events', label: 'Events' }, { to: '/admin/ssl', label: 'SSL / Domain' }, { to: '/admin/profile-access', label: 'Profilzugriff' }, + { to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' }, ]; export function AdminLayout() { diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 2c561c6..a806ebe 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -15,6 +15,7 @@ import { AdminSslPage } from '../admin/AdminSslPage'; import { AdminCompanyPage } from '../admin/AdminCompanyPage'; import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage'; import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage'; +import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage'; import { ProfilePage } from '../profile/ProfilePage'; import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; @@ -94,6 +95,7 @@ export function App() { } /> } /> } /> + } /> {/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */} } />