mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(crm): Projektanfrage-Vorgangstyp – isProjectType + ProjectRequestDetails
Backend: - DealType.isProjectType Boolean-Flag (Admin-konfigurierbar in CRM Settings) - Neue 1:1-Tabelle project_request_details (ON DELETE CASCADE): notes, workload, startDate, duration, onsitePercent, rateRemote, rateOnsite - Migration 20260313_project_request - ProjectRequestDto mit Validierung (0-100% fuer Auslastung/Vorort-Anteil) - Deals-Service: nested create + upsert fuer projectRequest Frontend: - DealFormModal: Vorgangsart-Dropdown an Anfang verschoben; Projektanfrage-Sektion erscheint conditional bei isProjectType=true (Beschreibung, Auslastung/Start, Laufzeit/Vorort-Anteil, Stundensaetze) - CrmSettingsPage: DealTypesConfig mit Projektanfrage-Checkbox + Tabellenspalte - types.ts: ProjectRequestDetails, CreateProjectRequestPayload, Deal.projectRequest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6bfce4af97
commit
4c739945f0
11 changed files with 494 additions and 46 deletions
23
Summarize.md
23
Summarize.md
|
|
@ -6,6 +6,29 @@
|
|||
|
||||
---
|
||||
|
||||
### Aenderungen 2026-03-13 (9): Projektanfrage-Vorgangstyp (isProjectType + ProjectRequestDetails)
|
||||
|
||||
#### Backend (crm-service)
|
||||
- `prisma/crm.schema.prisma` — `DealType` um `isProjectType Boolean @default(false)` erweitert; neues 1:1-Model `ProjectRequestDetails` (notes, workload, startDate, duration, onsitePercent, rateRemote, rateOnsite) mit `ON DELETE CASCADE` zu `Deal`; `Deal.projectRequest ProjectRequestDetails?` Relation hinzugefuegt
|
||||
- `prisma/migrations/20260313_project_request/migration.sql` — `ALTER TABLE deal_types ADD COLUMN is_project_type`; `CREATE TABLE project_request_details` mit Unique-Index auf deal_id und FK mit CASCADE
|
||||
- `src/deal-types/dto/create-deal-type.dto.ts` — `isProjectType?: boolean` (`@IsBoolean`) hinzugefuegt
|
||||
- `src/deal-types/dto/update-deal-type.dto.ts` — `isProjectType?: boolean` hinzugefuegt
|
||||
- `src/deals/dto/project-request.dto.ts` — Neues DTO: notes, workload (0-100%), startDate, duration, onsitePercent (0-100%), rateRemote, rateOnsite
|
||||
- `src/deals/dto/create-deal.dto.ts` — `projectRequest?: ProjectRequestDto` mit `@ValidateNested()` + `@Type(()=>ProjectRequestDto)` hinzugefuegt
|
||||
- `src/deals/deals.service.ts` — `create()`: Nested `projectRequest.create` + `include: {projectRequest:true}`; `findOne()`: `include: {projectRequest:true}`; `update()`: `projectRequest` aus Destructuring extrahiert, `upsert` vor Deal-Update, `include: {projectRequest:true}`
|
||||
|
||||
#### Frontend
|
||||
- `crm/types.ts` — `DealType.isProjectType: boolean`; `CreateDealTypePayload.isProjectType?`; neues Interface `ProjectRequestDetails`; `CreateProjectRequestPayload`; `Deal.projectRequest?`; `Deal.dealType.isProjectType` ergaenzt; `CreateDealPayload.projectRequest?`
|
||||
- `crm/deals/DealFormModal.tsx` — Vorgangsart-Dropdown an Anfang des Formulars; Projektanfrage-Sektion (grau hinterlegt, blauer Titel) erscheint conditional bei `isProjectType=true`: Beschreibung, Auslastung/Start-Zeile, Laufzeit/Vorort-Anteil-Zeile, Stundensatz Remote/Vorort-Zeile; Initialisierung aus `deal.projectRequest` beim Bearbeiten; Payload-Zusammenbau mit `projectRequest`-Objekt
|
||||
- `crm/settings/CrmSettingsPage.tsx` — `DealTypesConfig`: `isProjectType` State + Spalte "Projektanfrage" in Tabelle (Checkbox im Edit-/Add-Modus, ✓/— im View-Modus)
|
||||
|
||||
#### Deployment-Hinweis (Schritt 9)
|
||||
- Server: `prisma migrate deploy --schema prisma/crm.schema.prisma`
|
||||
- Server: `prisma generate --schema prisma/crm.schema.prisma`
|
||||
- Rebuild + Restart: crm-service + frontend
|
||||
|
||||
---
|
||||
|
||||
### Aenderungen 2026-03-13 (8): Vorgangsart (DealType) + Pipeline-Leerstate
|
||||
|
||||
#### Backend (crm-service)
|
||||
|
|
|
|||
|
|
@ -448,13 +448,14 @@ enum ContractStatus {
|
|||
// 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")
|
||||
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")
|
||||
isProjectType Boolean @default(false) @map("is_project_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
deals Deal[]
|
||||
|
||||
|
|
@ -550,13 +551,14 @@ model Deal {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
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[]
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
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[]
|
||||
projectRequest ProjectRequestDetails?
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, pipelineId])
|
||||
|
|
@ -576,6 +578,28 @@ enum DealStatus {
|
|||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// ProjectRequestDetails - Projektanfrage-Zusatzfelder (1:1 mit Deal)
|
||||
// --------------------------------------------------------
|
||||
model ProjectRequestDetails {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
dealId String @unique @map("deal_id") @db.Uuid
|
||||
notes String? @db.Text
|
||||
workload Decimal? @db.Decimal(5, 2)
|
||||
startDate DateTime? @map("start_date")
|
||||
duration String? @db.VarChar(200)
|
||||
onsitePercent Decimal? @map("onsite_percent") @db.Decimal(5, 2)
|
||||
rateRemote Decimal? @map("rate_remote") @db.Decimal(10, 2)
|
||||
rateOnsite Decimal? @map("rate_onsite") @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("project_request_details")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Office Integration - Voucher Types
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
-- Migration: 20260313_project_request
|
||||
-- Beschreibung: isProjectType auf deal_types + ProjectRequestDetails Tabelle
|
||||
|
||||
-- AlterTable: is_project_type auf deal_types
|
||||
ALTER TABLE "app_crm"."deal_types" ADD COLUMN "is_project_type" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable: project_request_details (1:1 mit deals, ON DELETE CASCADE)
|
||||
CREATE TABLE "app_crm"."project_request_details" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"deal_id" UUID NOT NULL,
|
||||
"notes" TEXT,
|
||||
"workload" DECIMAL(5,2),
|
||||
"start_date" TIMESTAMP(3),
|
||||
"duration" VARCHAR(200),
|
||||
"onsite_percent" DECIMAL(5,2),
|
||||
"rate_remote" DECIMAL(10,2),
|
||||
"rate_onsite" DECIMAL(10,2),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_request_details_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex (unique: jeder Deal max. 1 Projektanfrage)
|
||||
CREATE UNIQUE INDEX "project_request_details_deal_id_key" ON "app_crm"."project_request_details"("deal_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "app_crm"."project_request_details"
|
||||
ADD CONSTRAINT "project_request_details_deal_id_fkey"
|
||||
FOREIGN KEY ("deal_id")
|
||||
REFERENCES "app_crm"."deals"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
Matches,
|
||||
Min,
|
||||
|
|
@ -32,4 +33,12 @@ export class CreateDealTypeDto {
|
|||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Spezialtyp: Projektanfrage (zeigt zusaetzliche Felder im Vorgangs-Formular)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isProjectType?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
Matches,
|
||||
Min,
|
||||
|
|
@ -29,4 +30,9 @@ export class UpdateDealTypeDto {
|
|||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Spezialtyp: Projektanfrage' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isProjectType?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,23 @@ export class DealsService {
|
|||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
...(dto.projectRequest
|
||||
? {
|
||||
projectRequest: {
|
||||
create: {
|
||||
notes: dto.projectRequest.notes,
|
||||
workload: dto.projectRequest.workload,
|
||||
startDate: dto.projectRequest.startDate
|
||||
? new Date(dto.projectRequest.startDate)
|
||||
: undefined,
|
||||
duration: dto.projectRequest.duration,
|
||||
onsitePercent: dto.projectRequest.onsitePercent,
|
||||
rateRemote: dto.projectRequest.rateRemote,
|
||||
rateOnsite: dto.projectRequest.rateOnsite,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
|
|
@ -98,6 +115,7 @@ export class DealsService {
|
|||
},
|
||||
},
|
||||
owners: true,
|
||||
projectRequest: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -184,6 +202,7 @@ export class DealsService {
|
|||
contact: true,
|
||||
company: true,
|
||||
owners: true,
|
||||
projectRequest: true,
|
||||
dealVouchers: {
|
||||
include: {
|
||||
voucher: {
|
||||
|
|
@ -248,7 +267,7 @@ export class DealsService {
|
|||
}
|
||||
}
|
||||
|
||||
const { lostReason, lostReasonText, ...restDto } = dto;
|
||||
const { lostReason, lostReasonText, projectRequest, ...restDto } = dto;
|
||||
|
||||
const updateData: Prisma.DealUpdateInput = {
|
||||
...restDto,
|
||||
|
|
@ -277,6 +296,36 @@ export class DealsService {
|
|||
updateData.closedAt = new Date();
|
||||
}
|
||||
|
||||
// ProjectRequest upsert (vor Deal-Update damit Include aktuelle Daten liefert)
|
||||
if (projectRequest !== undefined && projectRequest !== null) {
|
||||
await this.prisma.projectRequestDetails.upsert({
|
||||
where: { dealId: id },
|
||||
create: {
|
||||
dealId: id,
|
||||
notes: projectRequest.notes,
|
||||
workload: projectRequest.workload,
|
||||
startDate: projectRequest.startDate
|
||||
? new Date(projectRequest.startDate)
|
||||
: undefined,
|
||||
duration: projectRequest.duration,
|
||||
onsitePercent: projectRequest.onsitePercent,
|
||||
rateRemote: projectRequest.rateRemote,
|
||||
rateOnsite: projectRequest.rateOnsite,
|
||||
},
|
||||
update: {
|
||||
notes: projectRequest.notes,
|
||||
workload: projectRequest.workload,
|
||||
startDate: projectRequest.startDate
|
||||
? new Date(projectRequest.startDate)
|
||||
: undefined,
|
||||
duration: projectRequest.duration,
|
||||
onsitePercent: projectRequest.onsitePercent,
|
||||
rateRemote: projectRequest.rateRemote,
|
||||
rateOnsite: projectRequest.rateOnsite,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
|
|
@ -298,6 +347,7 @@ export class DealsService {
|
|||
},
|
||||
},
|
||||
owners: true,
|
||||
projectRequest: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import {
|
|||
IsEnum,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectRequestDto } from './project-request.dto';
|
||||
|
||||
export enum DealStatus {
|
||||
OPEN = 'OPEN',
|
||||
|
|
@ -89,4 +92,13 @@ export class CreateDealDto {
|
|||
@IsOptional()
|
||||
@IsUUID()
|
||||
dealTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Projektanfrage-Details (nur bei Vorgangsart mit isProjectType=true)',
|
||||
type: () => ProjectRequestDto,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ProjectRequestDto)
|
||||
projectRequest?: ProjectRequestDto;
|
||||
}
|
||||
|
|
|
|||
71
packages/crm-service/src/deals/dto/project-request.dto.ts
Normal file
71
packages/crm-service/src/deals/dto/project-request.dto.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ProjectRequestDto {
|
||||
@ApiPropertyOptional({ description: 'Freitext / Projektbeschreibung' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Auslastung in % (0–100)',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
workload?: number;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time', description: 'Projektstart' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Laufzeit (Freitext, z.B. "6 Monate")',
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
duration?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Vorort-Anteil in % (0–100)',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
onsitePercent?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stundensatz Remote (€/h)',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
rateRemote?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stundensatz Vorort (€/h)',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
rateOnsite?: number;
|
||||
}
|
||||
|
|
@ -45,6 +45,23 @@ const rowStyle: React.CSSProperties = {
|
|||
gap: '0.75rem',
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
padding: '1rem',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: '1rem',
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-primary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '0.75rem',
|
||||
};
|
||||
|
||||
export function DealFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -84,6 +101,15 @@ export function DealFormModal({
|
|||
const [lostReasonText, setLostReasonText] = useState('');
|
||||
const [dealTypeId, setDealTypeId] = useState('');
|
||||
|
||||
// Projektanfrage-Felder
|
||||
const [prNotes, setPrNotes] = useState('');
|
||||
const [prWorkload, setPrWorkload] = useState('');
|
||||
const [prStartDate, setPrStartDate] = useState('');
|
||||
const [prDuration, setPrDuration] = useState('');
|
||||
const [prOnsitePercent, setPrOnsitePercent] = useState('');
|
||||
const [prRateRemote, setPrRateRemote] = useState('');
|
||||
const [prRateOnsite, setPrRateOnsite] = useState('');
|
||||
|
||||
// Kontakt-Suche
|
||||
const [contactSearch, setContactSearch] = useState('');
|
||||
const [contactResults, setContactResults] = useState<Contact[]>([]);
|
||||
|
|
@ -112,6 +138,10 @@ export function DealFormModal({
|
|||
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
: [];
|
||||
|
||||
// Ausgewaehlte Vorgangsart → Projektanfrage-Flag
|
||||
const selectedDealType = dealTypes.find((dt) => dt.id === dealTypeId) ?? null;
|
||||
const isProjectDeal = selectedDealType?.isProjectType === true;
|
||||
|
||||
// Click-Outside für Kontakt- und Unternehmen-Dropdown
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
@ -199,6 +229,15 @@ export function DealFormModal({
|
|||
setLostReason((deal.lostReason as LostReason) ?? '');
|
||||
setLostReasonText(deal.lostReasonText ?? '');
|
||||
setDealTypeId(deal.dealTypeId ?? '');
|
||||
// Projektanfrage initialisieren
|
||||
const pr = deal.projectRequest;
|
||||
setPrNotes(pr?.notes ?? '');
|
||||
setPrWorkload(pr?.workload ? String(parseFloat(pr.workload)) : '');
|
||||
setPrStartDate(pr?.startDate ? pr.startDate.slice(0, 10) : '');
|
||||
setPrDuration(pr?.duration ?? '');
|
||||
setPrOnsitePercent(pr?.onsitePercent ? String(parseFloat(pr.onsitePercent)) : '');
|
||||
setPrRateRemote(pr?.rateRemote ? String(parseFloat(pr.rateRemote)) : '');
|
||||
setPrRateOnsite(pr?.rateOnsite ? String(parseFloat(pr.rateOnsite)) : '');
|
||||
if (deal.contact) {
|
||||
const { id, firstName, lastName, companyName } = deal.contact;
|
||||
const name =
|
||||
|
|
@ -230,6 +269,13 @@ export function DealFormModal({
|
|||
setLostReason('');
|
||||
setLostReasonText('');
|
||||
setDealTypeId('');
|
||||
setPrNotes('');
|
||||
setPrWorkload('');
|
||||
setPrStartDate('');
|
||||
setPrDuration('');
|
||||
setPrOnsitePercent('');
|
||||
setPrRateRemote('');
|
||||
setPrRateOnsite('');
|
||||
setSelectedContact(null);
|
||||
setContactSearch('');
|
||||
setSelectedCompany(null);
|
||||
|
|
@ -270,6 +316,11 @@ export function DealFormModal({
|
|||
return;
|
||||
}
|
||||
|
||||
// Projektanfrage-Payload (nur wenn isProjectDeal und mind. ein Feld gesetzt)
|
||||
const hasProjectData = isProjectDeal && (
|
||||
prNotes || prWorkload || prStartDate || prDuration || prOnsitePercent || prRateRemote || prRateOnsite
|
||||
);
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
pipelineId,
|
||||
|
|
@ -284,6 +335,19 @@ export function DealFormModal({
|
|||
...(status === 'LOST' && lostReason ? { lostReason } : {}),
|
||||
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
|
||||
...(dealTypeId ? { dealTypeId } : {}),
|
||||
...(hasProjectData
|
||||
? {
|
||||
projectRequest: {
|
||||
...(prNotes ? { notes: prNotes } : {}),
|
||||
...(prWorkload ? { workload: parseFloat(prWorkload) } : {}),
|
||||
...(prStartDate ? { startDate: new Date(prStartDate).toISOString() } : {}),
|
||||
...(prDuration ? { duration: prDuration } : {}),
|
||||
...(prOnsitePercent ? { onsitePercent: parseFloat(prOnsitePercent) } : {}),
|
||||
...(prRateRemote ? { rateRemote: parseFloat(prRateRemote) } : {}),
|
||||
...(prRateOnsite ? { rateOnsite: parseFloat(prRateOnsite) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const saveCustomFields = (entityId: string) => {
|
||||
|
|
@ -354,6 +418,130 @@ export function DealFormModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Vorgangsart – ganz oben, damit Projektanfrage-Felder rechtzeitig erscheinen */}
|
||||
<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}
|
||||
{dt.isProjectType ? ' 🗂' : ''}
|
||||
</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>
|
||||
|
||||
{/* Projektanfrage-Sektion (nur bei isProjectType) */}
|
||||
{isProjectDeal && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>📋 Projektanfrage</div>
|
||||
|
||||
{/* Freitext */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelStyle}>Beschreibung</label>
|
||||
<textarea
|
||||
style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }}
|
||||
value={prNotes}
|
||||
onChange={(e) => setPrNotes(e.target.value)}
|
||||
placeholder="Projektbeschreibung, Anforderungen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auslastung + Start */}
|
||||
<div style={{ ...rowStyle, marginBottom: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Auslastung (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
style={inputStyle}
|
||||
value={prWorkload}
|
||||
onChange={(e) => setPrWorkload(e.target.value)}
|
||||
placeholder="z.B. 80"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Start</label>
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={prStartDate}
|
||||
onChange={(e) => setPrStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laufzeit + Vorort-Anteil */}
|
||||
<div style={{ ...rowStyle, marginBottom: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Laufzeit</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={prDuration}
|
||||
onChange={(e) => setPrDuration(e.target.value)}
|
||||
placeholder="z.B. 6 Monate"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Vorort-Anteil (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
style={inputStyle}
|
||||
value={prOnsitePercent}
|
||||
onChange={(e) => setPrOnsitePercent(e.target.value)}
|
||||
placeholder="z.B. 20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stundensätze */}
|
||||
<div style={rowStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Stundensatz Remote (€/h)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
style={inputStyle}
|
||||
value={prRateRemote}
|
||||
onChange={(e) => setPrRateRemote(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Stundensatz Vorort (€/h)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
style={inputStyle}
|
||||
value={prRateOnsite}
|
||||
onChange={(e) => setPrRateOnsite(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Titel */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Titel *</label>
|
||||
|
|
@ -425,31 +613,6 @@ 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>
|
||||
|
|
@ -594,7 +757,7 @@ export function DealFormModal({
|
|||
right: 0,
|
||||
background: 'var(--color-bg-card)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
borderRadius: 'var(--shadow-md)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
zIndex: 10,
|
||||
maxHeight: 200,
|
||||
|
|
|
|||
|
|
@ -709,11 +709,13 @@ function DealTypesConfig() {
|
|||
const [addMode, setAddMode] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6B7280');
|
||||
const [isProjectType, setIsProjectType] = useState(false);
|
||||
|
||||
const startAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setName('');
|
||||
setColor('#6B7280');
|
||||
setIsProjectType(false);
|
||||
setAddMode(true);
|
||||
}, []);
|
||||
|
||||
|
|
@ -722,6 +724,7 @@ function DealTypesConfig() {
|
|||
setEditId(item.id);
|
||||
setName(item.name);
|
||||
setColor(item.color);
|
||||
setIsProjectType(item.isProjectType ?? false);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
|
|
@ -729,22 +732,23 @@ function DealTypesConfig() {
|
|||
setAddMode(false);
|
||||
setName('');
|
||||
setColor('#6B7280');
|
||||
setIsProjectType(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!name.trim()) return;
|
||||
if (addMode) {
|
||||
createMut.mutate(
|
||||
{ name: name.trim(), color },
|
||||
{ name: name.trim(), color, isProjectType },
|
||||
{ onSuccess: cancel },
|
||||
);
|
||||
} else if (editId) {
|
||||
updateMut.mutate(
|
||||
{ id: editId, data: { name: name.trim(), color } },
|
||||
{ id: editId, data: { name: name.trim(), color, isProjectType } },
|
||||
{ onSuccess: cancel },
|
||||
);
|
||||
}
|
||||
}, [addMode, editId, name, color, createMut, updateMut, cancel]);
|
||||
}, [addMode, editId, name, color, isProjectType, createMut, updateMut, cancel]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
|
|
@ -788,6 +792,7 @@ function DealTypesConfig() {
|
|||
<th style={{ width: 40 }}>#</th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 60 }}>Farbe</th>
|
||||
<th style={{ width: 110, textAlign: 'center' }} title="Zeigt Projektanfrage-Felder im Vorgangs-Formular">Projektanfrage</th>
|
||||
<th style={{ width: 100, textAlign: 'right' }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -819,6 +824,14 @@ function DealTypesConfig() {
|
|||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isProjectType}
|
||||
onChange={(e) => setIsProjectType(e.target.checked)}
|
||||
title="Als Projektanfrage-Typ markieren"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={!name.trim() || isSaving}>
|
||||
|
|
@ -833,7 +846,7 @@ function DealTypesConfig() {
|
|||
)}
|
||||
{items.length === 0 && !addMode && (
|
||||
<tr>
|
||||
<td colSpan={4} className={styles.emptyRow}>
|
||||
<td colSpan={5} className={styles.emptyRow}>
|
||||
Noch keine Vorgangsarten definiert
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -865,6 +878,14 @@ function DealTypesConfig() {
|
|||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isProjectType}
|
||||
onChange={(e) => setIsProjectType(e.target.checked)}
|
||||
title="Als Projektanfrage-Typ markieren"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={!name.trim() || isSaving}>
|
||||
|
|
@ -906,6 +927,13 @@ function DealTypesConfig() {
|
|||
title={item.color}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{item.isProjectType ? (
|
||||
<span title="Projektanfrage-Typ" style={{ color: 'var(--color-primary)', fontSize: '1rem' }}>✓</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actionsCell}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export interface DealType {
|
|||
name: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
isProjectType: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: { deals: number };
|
||||
|
|
@ -162,10 +163,37 @@ export interface CreateDealTypePayload {
|
|||
name: string;
|
||||
color?: string;
|
||||
sortOrder?: number;
|
||||
isProjectType?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateDealTypePayload = Partial<CreateDealTypePayload>;
|
||||
|
||||
// --- ProjectRequestDetails ---
|
||||
|
||||
export interface ProjectRequestDetails {
|
||||
id: string;
|
||||
dealId: string;
|
||||
notes: string | null;
|
||||
workload: string | null; // Decimal kommt als String vom Backend
|
||||
startDate: string | null;
|
||||
duration: string | null;
|
||||
onsitePercent: string | null; // Decimal als String
|
||||
rateRemote: string | null; // Decimal als String
|
||||
rateOnsite: string | null; // Decimal als String
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequestPayload {
|
||||
notes?: string;
|
||||
workload?: number;
|
||||
startDate?: string;
|
||||
duration?: string;
|
||||
onsitePercent?: number;
|
||||
rateRemote?: number;
|
||||
rateOnsite?: number;
|
||||
}
|
||||
|
||||
export interface CompanyRelationship {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
|
|
@ -468,7 +496,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;
|
||||
dealType?: { id: string; name: string; color: string; isProjectType: boolean } | null;
|
||||
contact?: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
|
|
@ -477,6 +505,7 @@ export interface Deal {
|
|||
} | null;
|
||||
company?: { id: string; name: string } | null;
|
||||
dealVouchers?: DealVoucher[];
|
||||
projectRequest?: ProjectRequestDetails | null;
|
||||
}
|
||||
|
||||
export interface CreateDealPayload {
|
||||
|
|
@ -493,6 +522,7 @@ export interface CreateDealPayload {
|
|||
lostReason?: LostReason;
|
||||
lostReasonText?: string;
|
||||
dealTypeId?: string;
|
||||
projectRequest?: CreateProjectRequestPayload;
|
||||
}
|
||||
|
||||
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue