diff --git a/Summarize.md b/Summarize.md index 4aec16f..0db2966 100644 --- a/Summarize.md +++ b/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) diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index 6172f25..4e4e98d 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -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 // -------------------------------------------------------- diff --git a/packages/crm-service/prisma/migrations/20260313_project_request/migration.sql b/packages/crm-service/prisma/migrations/20260313_project_request/migration.sql new file mode 100644 index 0000000..3a74f9c --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260313_project_request/migration.sql @@ -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; 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 index 5fab884..d672b0e 100644 --- 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 @@ -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; } 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 index 599d475..c65361a 100644 --- 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 @@ -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; } diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index b6afd7a..ed0b447 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -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, }, }); 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 7e81a2f..ab25de5 100644 --- a/packages/crm-service/src/deals/dto/create-deal.dto.ts +++ b/packages/crm-service/src/deals/dto/create-deal.dto.ts @@ -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; } diff --git a/packages/crm-service/src/deals/dto/project-request.dto.ts b/packages/crm-service/src/deals/dto/project-request.dto.ts new file mode 100644 index 0000000..8007a24 --- /dev/null +++ b/packages/crm-service/src/deals/dto/project-request.dto.ts @@ -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; +} diff --git a/packages/frontend/src/crm/deals/DealFormModal.tsx b/packages/frontend/src/crm/deals/DealFormModal.tsx index 1d42e8f..a6e1a9e 100644 --- a/packages/frontend/src/crm/deals/DealFormModal.tsx +++ b/packages/frontend/src/crm/deals/DealFormModal.tsx @@ -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([]); @@ -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({ )} + {/* Vorgangsart – ganz oben, damit Projektanfrage-Felder rechtzeitig erscheinen */} +
+ + + {dealTypes.length === 0 && ( +

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

+ )} +
+ + {/* Projektanfrage-Sektion (nur bei isProjectType) */} + {isProjectDeal && ( +
+
📋 Projektanfrage
+ + {/* Freitext */} +
+ +