mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
feat(crm): Vorgangsart (DealType) konfigurierbares Dropdown + Pipeline-Leerstate
- crm-service: Neues DealType-Model (deal_types Tabelle) mit name, color, sortOrder und Relation zu Deal.dealTypeId; Migration 20260313_deal_type - crm-service: Vollstaendiger CRUD REST-Endpoint /crm/deal-types (TenantGuard) - crm-service: CreateDealDto um optionales dealTypeId erweitert - frontend: DealType Interface, API (dealTypesApi), Hooks (useDealTypes/...) - frontend: CrmSettingsPage > Weitere Einstellungen > DealTypesConfig mit Farbpicker - frontend: DealFormModal: Vorgangsart-Dropdown + Hinweis bei leerer Pipeline-Liste Deployment: prisma migrate deploy && prisma generate im crm-service ausfuehren. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f72ac6cb90
commit
6bfce4af97
16 changed files with 742 additions and 1 deletions
26
Summarize.md
26
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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
89
packages/crm-service/src/deal-types/deal-types.controller.ts
Normal file
89
packages/crm-service/src/deal-types/deal-types.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
packages/crm-service/src/deal-types/deal-types.module.ts
Normal file
10
packages/crm-service/src/deal-types/deal-types.module.ts
Normal file
|
|
@ -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 {}
|
||||
93
packages/crm-service/src/deal-types/deal-types.service.ts
Normal file
93
packages/crm-service/src/deal-types/deal-types.service.ts
Normal file
|
|
@ -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 } });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -84,4 +84,9 @@ export class CreateDealDto {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
lostReasonText?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Vorgangsart (konfigurierbar)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
dealTypeId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SingleResponse<DealType>>('/crm/deal-types', data)
|
||||
.then((r) => r.data),
|
||||
|
||||
update: (id: string, data: UpdateDealTypePayload) =>
|
||||
api
|
||||
.patch<SingleResponse<DealType>>(`/crm/deal-types/${id}`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api
|
||||
.delete<SingleResponse<DealType>>(`/crm/deal-types/${id}`)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Company Relationships ---
|
||||
|
||||
export const companyRelationshipsApi = {
|
||||
|
|
|
|||
|
|
@ -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<LostReason | ''>('');
|
||||
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({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Pipeline leer – Hinweis */}
|
||||
{pipelines.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--color-warning-bg, #fffbeb)',
|
||||
border: '1px solid var(--color-warning-border, #fde68a)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--color-warning, #92400e)',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
⚠️ Keine Pipelines vorhanden. Bitte zuerst unter{' '}
|
||||
<a href="/crm/settings" style={{ color: 'inherit', fontWeight: 600 }}>
|
||||
CRM Einstellungen → Pipelines
|
||||
</a>{' '}
|
||||
eine Pipeline anlegen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline + Stage */}
|
||||
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||
<div>
|
||||
|
|
@ -398,6 +425,31 @@ export function DealFormModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vorgangsart */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Vorgangsart</label>
|
||||
<select
|
||||
value={dealTypeId}
|
||||
onChange={(e) => setDealTypeId(e.target.value)}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">— Keine —</option>
|
||||
{dealTypes.map((dt) => (
|
||||
<option key={dt.id} value={dt.id}>
|
||||
{dt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{dealTypes.length === 0 && (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
|
||||
Keine Vorgangsarten konfiguriert.{' '}
|
||||
<a href="/crm/settings" style={{ color: 'var(--color-primary)' }}>
|
||||
In CRM Einstellungen anlegen
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kontakt-Suche */}
|
||||
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={contactRef}>
|
||||
<label style={labelStyle}>Kontakt</label>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.configHeader}>
|
||||
<div>
|
||||
<h2 className={styles.cardTitle}>Vorgangsarten</h2>
|
||||
<p className={styles.cardDesc} style={{ marginBottom: 0 }}>
|
||||
Kategorien für Vorgänge (z.B. Neukunde, Nachkauf, Partneranfrage). Farben werden als Badge angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
<button className={styles.addBtn} onClick={startAdd} disabled={addMode}>
|
||||
<PlusIcon /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className={styles.configTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>#</th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 60 }}>Farbe</th>
|
||||
<th style={{ width: 100, textAlign: 'right' }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{addMode && (
|
||||
<tr>
|
||||
<td>—</td>
|
||||
<td>
|
||||
<div className={styles.inlineForm}>
|
||||
<input
|
||||
className={styles.inlineInput}
|
||||
style={{ flex: 1 }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Vorgangsart-Name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') cancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={!name.trim() || isSaving}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className={styles.cancelBtn} onClick={cancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.length === 0 && !addMode && (
|
||||
<tr>
|
||||
<td colSpan={4} className={styles.emptyRow}>
|
||||
Noch keine Vorgangsarten definiert
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((item, idx) =>
|
||||
editId === item.id ? (
|
||||
<tr key={item.id}>
|
||||
<td>{idx + 1}</td>
|
||||
<td>
|
||||
<div className={styles.inlineForm}>
|
||||
<input
|
||||
className={styles.inlineInput}
|
||||
style={{ flex: 1 }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') cancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={!name.trim() || isSaving}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className={styles.cancelBtn} onClick={cancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className={styles.sortBtns}>
|
||||
<button
|
||||
className={styles.sortBtn}
|
||||
onClick={() => handleSort(item, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Nach oben"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
<button
|
||||
className={styles.sortBtn}
|
||||
onClick={() => handleSort(item, 'down')}
|
||||
disabled={idx === items.length - 1}
|
||||
title="Nach unten"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{item.name}</td>
|
||||
<td>
|
||||
<span
|
||||
className={styles.colorDot}
|
||||
style={{ backgroundColor: item.color }}
|
||||
title={item.color}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => startEdit(item)}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CustomFieldsConfig (Phase 2.1)
|
||||
// ============================================================
|
||||
|
|
@ -1640,6 +1885,7 @@ export function CrmSettingsPage() {
|
|||
{/* Tab: Weitere Einstellungen */}
|
||||
{activeTab === 'settings' && (
|
||||
<>
|
||||
<DealTypesConfig />
|
||||
<IndustriesConfig />
|
||||
<AccountTypesConfig />
|
||||
<RelationshipTypesConfig />
|
||||
|
|
|
|||
|
|
@ -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<CreateDealTypePayload>;
|
||||
|
||||
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<CreateDealPayload>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue