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:
Thomas Reitz 2026-03-13 16:15:02 +01:00
parent f72ac6cb90
commit 6bfce4af97
16 changed files with 742 additions and 1 deletions

View file

@ -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

View file

@ -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[]

View file

@ -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;

View file

@ -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: [
{

View 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);
}
}

View 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 {}

View 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 } });
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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' },

View file

@ -84,4 +84,9 @@ export class CreateDealDto {
@IsOptional()
@IsString()
lostReasonText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Vorgangsart (konfigurierbar)' })
@IsOptional()
@IsUUID()
dealTypeId?: string;
}

View file

@ -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 = {

View file

@ -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&nbsp;Einstellungen&nbsp;&nbsp;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>

View file

@ -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
// ============================================================

View file

@ -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 />

View file

@ -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>;