diff --git a/Summarize.md b/Summarize.md index 4ef6bf6..4aec16f 100644 --- a/Summarize.md +++ b/Summarize.md @@ -6,6 +6,32 @@ --- +### Aenderungen 2026-03-13 (8): Vorgangsart (DealType) + Pipeline-Leerstate + +#### Backend (crm-service) +- `prisma/crm.schema.prisma` — Neues Model `DealType` (id, tenantId, name, color, sortOrder) mit `@@unique([tenantId, name])`, `@@map("deal_types")`; Relation `deals Deal[]`; Deal-Model um optionales Feld `dealTypeId String? @map("deal_type_id") @db.Uuid` + Relation `dealType DealType? @relation(...)` erweitert +- `prisma/migrations/20260313_deal_type/migration.sql` — Migration: CREATE TABLE `app_crm.deal_types`, ALTER TABLE `app_crm.deals` ADD COLUMN `deal_type_id`, Unique/Index/FK-Constraints +- `src/deal-types/dto/create-deal-type.dto.ts` — DTO: name (required), color (optional, Hex-Validierung), sortOrder (optional) +- `src/deal-types/dto/update-deal-type.dto.ts` — Alle Felder optional +- `src/deal-types/deal-types.service.ts` — CRUD-Service (findAll/create/update/remove) analog IndustriesService, Duplikat-Check, Nutzungs-Guard beim Loeschen +- `src/deal-types/deal-types.controller.ts` — REST-Controller: GET/POST/PATCH/DELETE unter `/crm/deal-types`, TenantGuard +- `src/deal-types/deal-types.module.ts` — NestJS-Modul +- `src/app.module.ts` — DealTypesModule registriert +- `src/deals/dto/create-deal.dto.ts` — `dealTypeId?: string` (UUID, optional) hinzugefuegt +- `src/deals/deals.service.ts` — `dealTypeId` beim Create an Prisma weitergegeben + +#### Frontend +- `crm/types.ts` — Neues Interface `DealType` (id, tenantId, name, color, sortOrder, _count?); `CreateDealTypePayload`, `UpdateDealTypePayload`; `Deal.dealTypeId: string|null` + `dealType?: {...}|null`; `CreateDealPayload.dealTypeId?: string` +- `crm/api.ts` — `dealTypesApi` (list/create/update/delete auf `/crm/deal-types`) importiert; Typ-Imports erweitert +- `crm/hooks.ts` — `crmKeys.dealTypes`, `useDealTypes`, `useCreateDealType`, `useUpdateDealType`, `useDeleteDealType` +- `crm/settings/CrmSettingsPage.tsx` — `DealTypesConfig`-Komponente (analog IndustriesConfig mit Farbpicker, Sortierung); als erster Block im Tab "Weitere Einstellungen" platziert +- `crm/deals/DealFormModal.tsx` — Vorgangsart-Dropdown (select mit DealType-Optionen, "— Keine —" als Default); Pipeline-Leerstate-Hinweis mit Link zu CRM-Einstellungen; `dealTypeId` State + Payload-Uebergabe + +#### Deployment-Hinweis +Nach Deploy: `npx prisma migrate deploy && npx prisma generate` im crm-service ausfuehren. + +--- + ### Aenderungen 2026-03-13 (7): Profil-Bereich nach oben rechts verschoben (Topbar) #### Frontend diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index f8f2f66..6172f25 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -444,6 +444,26 @@ enum ContractStatus { @@schema("app_crm") } +// -------------------------------------------------------- +// DealType - Vorgangsart (admin-konfigurierbar pro Tenant) +// -------------------------------------------------------- +model DealType { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + name String @db.VarChar(100) + color String @default("#6B7280") @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + deals Deal[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("deal_types") + @@schema("app_crm") +} + // -------------------------------------------------------- // Pipeline - Sales-Pipelines (konfigurierbar pro Tenant) // -------------------------------------------------------- @@ -515,6 +535,9 @@ model Deal { closedAt DateTime? @map("closed_at") notes String? @db.Text + // Vorgangsart + dealTypeId String? @map("deal_type_id") @db.Uuid + // Phase 1: Lost-Reason lostReason LostReason? @map("lost_reason") lostReasonText String? @map("lost_reason_text") @db.Text @@ -531,6 +554,7 @@ model Deal { stage PipelineStage @relation(fields: [stageId], references: [id]) contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealType DealType? @relation(fields: [dealTypeId], references: [id], onDelete: SetNull) dealVouchers DealVoucher[] owners DealOwner[] diff --git a/packages/crm-service/prisma/migrations/20260313_deal_type/migration.sql b/packages/crm-service/prisma/migrations/20260313_deal_type/migration.sql new file mode 100644 index 0000000..2587c58 --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260313_deal_type/migration.sql @@ -0,0 +1,25 @@ +-- Migration: 20260313_deal_type +-- Beschreibung: Vorgangsart (DealType) Tabelle + deal_type_id auf deals + +-- CreateTable +CREATE TABLE "app_crm"."deal_types" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "color" VARCHAR(7) NOT NULL DEFAULT '#6B7280', + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "deal_types_pkey" PRIMARY KEY ("id") +); + +-- AlterTable: deal_type_id auf deals +ALTER TABLE "app_crm"."deals" ADD COLUMN "deal_type_id" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "deal_types_tenant_id_name_key" ON "app_crm"."deal_types"("tenant_id", "name"); +CREATE INDEX "deal_types_tenant_id_idx" ON "app_crm"."deal_types"("tenant_id"); + +-- AddForeignKey +ALTER TABLE "app_crm"."deals" ADD CONSTRAINT "deals_deal_type_id_fkey" FOREIGN KEY ("deal_type_id") REFERENCES "app_crm"."deal_types"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index 121f76e..b6ae826 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -26,6 +26,7 @@ import { ImportModule } from './import/import.module'; 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'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { GraphModule } from './graph/graph.module'; EnrichmentModule, ContractsModule, GraphModule, + DealTypesModule, ], providers: [ { diff --git a/packages/crm-service/src/deal-types/deal-types.controller.ts b/packages/crm-service/src/deal-types/deal-types.controller.ts new file mode 100644 index 0000000..667907a --- /dev/null +++ b/packages/crm-service/src/deal-types/deal-types.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { DealTypesService } from './deal-types.service'; +import { CreateDealTypeDto } from './dto/create-deal-type.dto'; +import { UpdateDealTypeDto } from './dto/update-deal-type.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('DealTypes') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('deal-types') +export class DealTypesController { + constructor(private readonly dealTypesService: DealTypesService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Vorgangsart erstellen' }) + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateDealTypeDto, + ) { + const dealType = await this.dealTypesService.create(user.tenantId!, dto); + return singleResponse(dealType); + } + + @Get() + @ApiOperation({ summary: 'Alle Vorgangsarten auflisten' }) + async findAll(@CurrentUser() user: JwtPayload) { + const dealTypes = await this.dealTypesService.findAll(user.tenantId!); + return { data: dealTypes }; + } + + @Get(':id') + @ApiOperation({ summary: 'Vorgangsart abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const dealType = await this.dealTypesService.findOne(user.tenantId!, id); + return singleResponse(dealType); + } + + @Patch(':id') + @ApiOperation({ summary: 'Vorgangsart aktualisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateDealTypeDto, + ) { + const dealType = await this.dealTypesService.update( + user.tenantId!, + id, + dto, + ); + return singleResponse(dealType); + } + + @Delete(':id') + @ApiOperation({ summary: 'Vorgangsart loeschen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const dealType = await this.dealTypesService.remove(user.tenantId!, id); + return singleResponse(dealType); + } +} diff --git a/packages/crm-service/src/deal-types/deal-types.module.ts b/packages/crm-service/src/deal-types/deal-types.module.ts new file mode 100644 index 0000000..5bdbabc --- /dev/null +++ b/packages/crm-service/src/deal-types/deal-types.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DealTypesController } from './deal-types.controller'; +import { DealTypesService } from './deal-types.service'; + +@Module({ + controllers: [DealTypesController], + providers: [DealTypesService], + exports: [DealTypesService], +}) +export class DealTypesModule {} diff --git a/packages/crm-service/src/deal-types/deal-types.service.ts b/packages/crm-service/src/deal-types/deal-types.service.ts new file mode 100644 index 0000000..9642b3a --- /dev/null +++ b/packages/crm-service/src/deal-types/deal-types.service.ts @@ -0,0 +1,93 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateDealTypeDto } from './dto/create-deal-type.dto'; +import { UpdateDealTypeDto } from './dto/update-deal-type.dto'; + +@Injectable() +export class DealTypesService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create(tenantId: string, dto: CreateDealTypeDto) { + const existing = await this.prisma.dealType.findUnique({ + where: { tenantId_name: { tenantId, name: dto.name } }, + }); + if (existing) { + throw new ConflictException( + `Vorgangsart "${dto.name}" existiert bereits`, + ); + } + + return this.prisma.dealType.create({ + data: { + tenantId, + name: dto.name, + color: dto.color ?? '#6B7280', + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async findAll(tenantId: string) { + return this.prisma.dealType.findMany({ + where: { tenantId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { deals: true } }, + }, + }); + } + + async findOne(tenantId: string, id: string) { + const dealType = await this.prisma.dealType.findFirst({ + where: { id, tenantId }, + include: { + _count: { select: { deals: true } }, + }, + }); + + if (!dealType) { + throw new NotFoundException('Vorgangsart nicht gefunden'); + } + + return dealType; + } + + async update(tenantId: string, id: string, dto: UpdateDealTypeDto) { + await this.findOne(tenantId, id); + + if (dto.name) { + const existing = await this.prisma.dealType.findFirst({ + where: { tenantId, name: dto.name, NOT: { id } }, + }); + if (existing) { + throw new ConflictException( + `Vorgangsart "${dto.name}" existiert bereits`, + ); + } + } + + return this.prisma.dealType.update({ + where: { id }, + data: dto, + include: { + _count: { select: { deals: true } }, + }, + }); + } + + async remove(tenantId: string, id: string) { + const dealType = await this.findOne(tenantId, id); + + if (dealType._count.deals > 0) { + throw new ConflictException( + `Vorgangsart kann nicht geloescht werden — ${dealType._count.deals} Vorgang/Vorgaenge zugeordnet`, + ); + } + + return this.prisma.dealType.delete({ where: { id } }); + } +} diff --git a/packages/crm-service/src/deal-types/dto/create-deal-type.dto.ts b/packages/crm-service/src/deal-types/dto/create-deal-type.dto.ts new file mode 100644 index 0000000..5fab884 --- /dev/null +++ b/packages/crm-service/src/deal-types/dto/create-deal-type.dto.ts @@ -0,0 +1,35 @@ +import { + IsString, + IsOptional, + IsInt, + MaxLength, + Matches, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateDealTypeDto { + @ApiProperty({ maxLength: 100, description: 'Name der Vorgangsart' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + maxLength: 7, + default: '#6B7280', + description: 'Hex-Farbcode (z.B. #3B82F6)', + }) + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'color muss ein gueltiger Hex-Farbcode sein (z.B. #3B82F6)', + }) + color?: string; + + @ApiPropertyOptional({ default: 0, description: 'Sortierreihenfolge' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/deal-types/dto/update-deal-type.dto.ts b/packages/crm-service/src/deal-types/dto/update-deal-type.dto.ts new file mode 100644 index 0000000..599d475 --- /dev/null +++ b/packages/crm-service/src/deal-types/dto/update-deal-type.dto.ts @@ -0,0 +1,32 @@ +import { + IsString, + IsOptional, + IsInt, + MaxLength, + Matches, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateDealTypeDto { + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional({ maxLength: 7 }) + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'color muss ein gueltiger Hex-Farbcode sein (z.B. #3B82F6)', + }) + color?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index f75adc0..b6afd7a 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -74,6 +74,7 @@ export class DealsService { notes: dto.notes, lostReason: dto.lostReason, lostReasonText: dto.lostReasonText, + dealTypeId: dto.dealTypeId, createdBy: userId, owners: { create: { tenantId, userId, role: 'OWNER' }, diff --git a/packages/crm-service/src/deals/dto/create-deal.dto.ts b/packages/crm-service/src/deals/dto/create-deal.dto.ts index 819da52..7e81a2f 100644 --- a/packages/crm-service/src/deals/dto/create-deal.dto.ts +++ b/packages/crm-service/src/deals/dto/create-deal.dto.ts @@ -84,4 +84,9 @@ export class CreateDealDto { @IsOptional() @IsString() lostReasonText?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Vorgangsart (konfigurierbar)' }) + @IsOptional() + @IsUUID() + dealTypeId?: string; } diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index e7c4bc1..f85d50d 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -36,6 +36,9 @@ import type { RelationshipType, CreateRelationshipTypePayload, UpdateRelationshipTypePayload, + DealType, + CreateDealTypePayload, + UpdateDealTypePayload, CompanyRelationship, CreateCompanyRelationshipPayload, TenantUser, @@ -392,6 +395,32 @@ export const relationshipTypesApi = { .then((r) => r.data), }; +// --- Deal Types --- + +export const dealTypesApi = { + list: () => + api + .get<{ success: boolean; data: DealType[]; meta: { timestamp: string } }>( + '/crm/deal-types', + ) + .then((r) => r.data), + + create: (data: CreateDealTypePayload) => + api + .post>('/crm/deal-types', data) + .then((r) => r.data), + + update: (id: string, data: UpdateDealTypePayload) => + api + .patch>(`/crm/deal-types/${id}`, data) + .then((r) => r.data), + + delete: (id: string) => + api + .delete>(`/crm/deal-types/${id}`) + .then((r) => r.data), +}; + // --- Company Relationships --- export const companyRelationshipsApi = { diff --git a/packages/frontend/src/crm/deals/DealFormModal.tsx b/packages/frontend/src/crm/deals/DealFormModal.tsx index 206cc88..1d42e8f 100644 --- a/packages/frontend/src/crm/deals/DealFormModal.tsx +++ b/packages/frontend/src/crm/deals/DealFormModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Modal } from '../../components/Modal'; -import { useCreateDeal, useUpdateDeal, usePipelines, useSetCustomFieldValues } from '../hooks'; +import { useCreateDeal, useUpdateDeal, usePipelines, useDealTypes, useSetCustomFieldValues } from '../hooks'; import { contactsApi, companiesApi } from '../api'; import { CustomFieldsForm } from '../CustomFieldsForm'; import type { Deal, DealStatus, LostReason, Contact, Company, CustomFieldValue } from '../types'; @@ -68,6 +68,8 @@ export function DealFormModal({ const { data: pipelinesData } = usePipelines(); const pipelines = pipelinesData?.data ?? []; + const { data: dealTypesData } = useDealTypes(); + const dealTypes = dealTypesData?.data ?? []; const [error, setError] = useState(''); const [title, setTitle] = useState(''); @@ -80,6 +82,7 @@ export function DealFormModal({ const [notes, setNotes] = useState(''); const [lostReason, setLostReason] = useState(''); const [lostReasonText, setLostReasonText] = useState(''); + const [dealTypeId, setDealTypeId] = useState(''); // Kontakt-Suche const [contactSearch, setContactSearch] = useState(''); @@ -195,6 +198,7 @@ export function DealFormModal({ setNotes(deal.notes ?? ''); setLostReason((deal.lostReason as LostReason) ?? ''); setLostReasonText(deal.lostReasonText ?? ''); + setDealTypeId(deal.dealTypeId ?? ''); if (deal.contact) { const { id, firstName, lastName, companyName } = deal.contact; const name = @@ -225,6 +229,7 @@ export function DealFormModal({ setNotes(''); setLostReason(''); setLostReasonText(''); + setDealTypeId(''); setSelectedContact(null); setContactSearch(''); setSelectedCompany(null); @@ -278,6 +283,7 @@ export function DealFormModal({ ...(notes ? { notes } : {}), ...(status === 'LOST' && lostReason ? { lostReason } : {}), ...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}), + ...(dealTypeId ? { dealTypeId } : {}), }; const saveCustomFields = (entityId: string) => { @@ -360,6 +366,27 @@ export function DealFormModal({ /> + {/* Pipeline leer – Hinweis */} + {pipelines.length === 0 && ( +
+ ⚠️ Keine Pipelines vorhanden. Bitte zuerst unter{' '} + + CRM Einstellungen → Pipelines + {' '} + eine Pipeline anlegen. +
+ )} + {/* Pipeline + Stage */}
@@ -398,6 +425,31 @@ export function DealFormModal({
+ {/* Vorgangsart */} +
+ + + {dealTypes.length === 0 && ( +

+ Keine Vorgangsarten konfiguriert.{' '} + + In CRM Einstellungen anlegen + +

+ )} +
+ {/* Kontakt-Suche */}
diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index bff349c..23e1763 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -27,6 +27,7 @@ import { integrationsApi, graphApi, office365Api, + dealTypesApi, } from './api'; import type { ContactsQueryParams, @@ -51,6 +52,8 @@ import type { UpdateAccountTypePayload, CreateRelationshipTypePayload, UpdateRelationshipTypePayload, + CreateDealTypePayload, + UpdateDealTypePayload, CreateCompanyRelationshipPayload, LexwareContactSearchParams, LexwareVouchersQueryParams, @@ -117,6 +120,10 @@ export const crmKeys = { all: ['crm', 'relationshipTypes'] as const, list: () => ['crm', 'relationshipTypes', 'list'] as const, }, + dealTypes: { + all: ['crm', 'dealTypes'] as const, + list: () => ['crm', 'dealTypes', 'list'] as const, + }, companyRelationships: { all: ['crm', 'companyRelationships'] as const, list: (companyId: string) => @@ -639,6 +646,49 @@ export function useDeleteRelationshipType() { }); } +// ============================================================ +// Deal Types (Vorgangsart) +// ============================================================ + +export function useDealTypes() { + return useQuery({ + queryKey: crmKeys.dealTypes.list(), + queryFn: () => dealTypesApi.list(), + staleTime: 10 * 60 * 1000, + }); +} + +export function useCreateDealType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateDealTypePayload) => dealTypesApi.create(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: crmKeys.dealTypes.all }); + }, + }); +} + +export function useUpdateDealType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateDealTypePayload }) => + dealTypesApi.update(id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: crmKeys.dealTypes.all }); + }, + }); +} + +export function useDeleteDealType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => dealTypesApi.delete(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: crmKeys.dealTypes.all }); + }, + }); +} + // ============================================================ // Company Relationships // ============================================================ diff --git a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx index feada14..2fbd976 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx +++ b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx @@ -18,6 +18,10 @@ import { useCreateRelationshipType, useUpdateRelationshipType, useDeleteRelationshipType, + useDealTypes, + useCreateDealType, + useUpdateDealType, + useDeleteDealType, useCustomFieldDefs, useCreateCustomFieldDef, useUpdateCustomFieldDef, @@ -29,6 +33,7 @@ import type { Industry, AccountType, RelationshipType, + DealType, CustomFieldDef, CustomFieldEntityType, CustomFieldType, @@ -688,6 +693,246 @@ function RelationshipTypesConfig() { ); } +// ============================================================ +// DealTypesConfig (Vorgangsart) +// ============================================================ + +function DealTypesConfig() { + const { data } = useDealTypes(); + const createMut = useCreateDealType(); + const updateMut = useUpdateDealType(); + const deleteMut = useDeleteDealType(); + + const items: DealType[] = data?.data ?? []; + + const [editId, setEditId] = useState(null); + const [addMode, setAddMode] = useState(false); + const [name, setName] = useState(''); + const [color, setColor] = useState('#6B7280'); + + const startAdd = useCallback(() => { + setEditId(null); + setName(''); + setColor('#6B7280'); + setAddMode(true); + }, []); + + const startEdit = useCallback((item: DealType) => { + setAddMode(false); + setEditId(item.id); + setName(item.name); + setColor(item.color); + }, []); + + const cancel = useCallback(() => { + setEditId(null); + setAddMode(false); + setName(''); + setColor('#6B7280'); + }, []); + + const handleSave = useCallback(() => { + if (!name.trim()) return; + if (addMode) { + createMut.mutate( + { name: name.trim(), color }, + { onSuccess: cancel }, + ); + } else if (editId) { + updateMut.mutate( + { id: editId, data: { name: name.trim(), color } }, + { onSuccess: cancel }, + ); + } + }, [addMode, editId, name, color, createMut, updateMut, cancel]); + + const handleDelete = useCallback( + (id: string) => { + if (window.confirm('Vorgangsart wirklich löschen?')) { + deleteMut.mutate(id); + } + }, + [deleteMut], + ); + + const handleSort = useCallback( + (item: DealType, direction: 'up' | 'down') => { + const newOrder = + direction === 'up' + ? Math.max(0, item.sortOrder - 1) + : item.sortOrder + 1; + updateMut.mutate({ id: item.id, data: { sortOrder: newOrder } }); + }, + [updateMut], + ); + + const isSaving = createMut.isPending || updateMut.isPending; + + return ( +
+
+
+

Vorgangsarten

+

+ Kategorien für Vorgänge (z.B. Neukunde, Nachkauf, Partneranfrage). Farben werden als Badge angezeigt. +

+
+ +
+ + + + + + + + + + + + {addMode && ( + + + + + + + )} + {items.length === 0 && !addMode && ( + + + + )} + {items.map((item, idx) => + editId === item.id ? ( + + + + + + + ) : ( + + + + + + + ), + )} + +
#NameFarbeAktionen
+
+ setName(e.target.value)} + placeholder="Vorgangsart-Name" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+ setColor(e.target.value)} + /> + +
+ + +
+
+ Noch keine Vorgangsarten definiert +
{idx + 1} +
+ setName(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+ setColor(e.target.value)} + /> + +
+ + +
+
+
+ + +
+
{item.name} + + +
+ + +
+
+
+ ); +} + // ============================================================ // CustomFieldsConfig (Phase 2.1) // ============================================================ @@ -1640,6 +1885,7 @@ export function CrmSettingsPage() { {/* Tab: Weitere Einstellungen */} {activeTab === 'settings' && ( <> + diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 06e833a..ef8b0e6 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -147,6 +147,25 @@ export interface RelationshipType { _count?: { relationships: number }; } +export interface DealType { + id: string; + tenantId: string; + name: string; + color: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + _count?: { deals: number }; +} + +export interface CreateDealTypePayload { + name: string; + color?: string; + sortOrder?: number; +} + +export type UpdateDealTypePayload = Partial; + export interface CompanyRelationship { id: string; tenantId: string; @@ -437,6 +456,7 @@ export interface Deal { status: DealStatus; expectedCloseDate: string | null; closedAt: string | null; + dealTypeId: string | null; lostReason: LostReason | null; lostReasonText: string | null; notes: string | null; @@ -448,6 +468,7 @@ export interface Deal { customFields?: CustomFieldValue[]; pipeline?: { id: string; name: string; stages?: PipelineStage[] }; stage?: { id: string; name: string; color: string }; + dealType?: { id: string; name: string; color: string } | null; contact?: { id: string; firstName: string | null; @@ -471,6 +492,7 @@ export interface CreateDealPayload { notes?: string; lostReason?: LostReason; lostReasonText?: string; + dealTypeId?: string; } export type UpdateDealPayload = Partial;