From aaedf68085d37fd94742b78fdb5f3faa70ddaf0f Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 18:22:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(crm):=20Phase=202.1=20Custom=20Fields=20?= =?UTF-8?q?=E2=80=94=20backend=20+=20frontend=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (CRM expert): Custom field definitions CRUD, bulk value upsert, 7 endpoints, Prisma schema with CustomFieldDef + CustomFieldValue tables. Frontend: Types, API, hooks, admin settings page with field management, CustomFieldsDisplay for detail pages, CustomFieldsForm for edit modals. Also fix Vite allowedHosts for insight.xinion.lan. Co-Authored-By: Claude Opus 4.6 --- docs/INSIGHT-CRM.md | 98 +++ packages/crm-service/Summarize.md | 33 +- packages/crm-service/prisma/crm.schema.prisma | 75 +++ .../migration.sql | 86 +++ packages/crm-service/src/app.module.ts | 2 + .../src/companies/companies.module.ts | 3 +- .../src/companies/companies.service.ts | 17 +- .../src/contacts/contacts.module.ts | 3 +- .../src/contacts/contacts.service.ts | 17 +- .../custom-fields/custom-fields.controller.ts | 156 +++++ .../src/custom-fields/custom-fields.module.ts | 10 + .../custom-fields/custom-fields.service.ts | 577 ++++++++++++++++++ .../dto/create-custom-field.dto.ts | 105 ++++ .../dto/set-custom-field-values.dto.ts | 35 ++ .../dto/update-custom-field.dto.ts | 51 ++ .../crm-service/src/deals/deals.module.ts | 3 +- .../crm-service/src/deals/deals.service.ts | 18 +- .../frontend/src/crm/CustomFieldsDisplay.tsx | 144 +++++ .../frontend/src/crm/CustomFieldsForm.tsx | 319 ++++++++++ packages/frontend/src/crm/api.ts | 60 ++ .../src/crm/companies/CompanyDetailPage.tsx | 6 + .../src/crm/companies/CompanyFormModal.tsx | 50 +- .../src/crm/contacts/ContactDetailPage.tsx | 6 + .../src/crm/contacts/ContactFormModal.tsx | 46 +- .../frontend/src/crm/deals/DealDetailPage.tsx | 6 + .../frontend/src/crm/deals/DealFormModal.tsx | 51 +- packages/frontend/src/crm/hooks.ts | 87 +++ .../src/crm/settings/CrmSettingsPage.tsx | 529 +++++++++++++++- packages/frontend/src/crm/types.ts | 85 +++ packages/frontend/vite.config.ts | 1 + 30 files changed, 2645 insertions(+), 34 deletions(-) create mode 100644 packages/crm-service/prisma/migrations/20260312_phase2_custom_fields/migration.sql create mode 100644 packages/crm-service/src/custom-fields/custom-fields.controller.ts create mode 100644 packages/crm-service/src/custom-fields/custom-fields.module.ts create mode 100644 packages/crm-service/src/custom-fields/custom-fields.service.ts create mode 100644 packages/crm-service/src/custom-fields/dto/create-custom-field.dto.ts create mode 100644 packages/crm-service/src/custom-fields/dto/set-custom-field-values.dto.ts create mode 100644 packages/crm-service/src/custom-fields/dto/update-custom-field.dto.ts create mode 100644 packages/frontend/src/crm/CustomFieldsDisplay.tsx create mode 100644 packages/frontend/src/crm/CustomFieldsForm.tsx diff --git a/docs/INSIGHT-CRM.md b/docs/INSIGHT-CRM.md index 79a339e..51ccd92 100644 --- a/docs/INSIGHT-CRM.md +++ b/docs/INSIGHT-CRM.md @@ -2193,3 +2193,101 @@ Bitte fuer jedes abgeschlossene Feature einen Eintrag in diese Datei schreiben: --- *Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`* + +--- + +## 2026-03-12 | CRM-Backend: Phase 2.1 — Custom Fields System + +### Neue Endpoints + +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| `POST` | `/api/v1/crm/custom-fields` | Feld-Definition erstellen | +| `GET` | `/api/v1/crm/custom-fields?entityType=PERSON` | Alle Feld-Definitionen auflisten (optional nach Entity-Typ gefiltert) | +| `GET` | `/api/v1/crm/custom-fields/:id` | Einzelne Feld-Definition abrufen | +| `PATCH` | `/api/v1/crm/custom-fields/:id` | Definition aktualisieren (Label, Options, Position, Required) | +| `DELETE` | `/api/v1/crm/custom-fields/:id` | Definition loeschen (CASCADE auf alle gespeicherten Werte!) | +| `PUT` | `/api/v1/crm/custom-fields/:entityId/values` | Custom-Field-Werte fuer eine Entity setzen (Bulk-Upsert) | +| `GET` | `/api/v1/crm/custom-fields/:entityId/values` | Custom-Field-Werte fuer eine Entity lesen | + +### Schema-Aenderungen + +**Neue Enums:** +- `CustomFieldEntityType`: PERSON, COMPANY, DEAL +- `CustomFieldType`: TEXT, TEXTAREA, NUMBER, DATE, DROPDOWN, MULTI_SELECT, CHECKBOX, URL + +**Neue Tabellen:** + +| Tabelle | Beschreibung | +|---------|-------------| +| `crm_custom_field_defs` | Feld-Definitionen pro Tenant + Entity-Typ. Unique: `[tenant_id, entity_type, name]`. Felder: entityType, name (Auto-Slug), label, fieldType (immutable), options (JSONB fuer DROPDOWN/MULTI_SELECT), isRequired, position | +| `crm_custom_field_values` | Gespeicherte Werte. Unique: `[field_def_id, entity_id]`. Spalten pro Typ: valueText, valueNumber, valueDate, valueBoolean, valueJson. FK auf Definitionen mit CASCADE Delete | + +**SQL Migration:** `prisma/migrations/20260312_phase2_custom_fields/migration.sql` + +### Response-Aenderungen + +**Contact-Detail, Company-Detail, Deal-Detail** enthalten jetzt ein neues Feld `customFields`: + +```json +{ + "id": "...", + "firstName": "Max", + "customFields": [ + { + "fieldDefId": "uuid", + "name": "kundennummer", + "label": "Kundennummer", + "fieldType": "TEXT", + "value": "KD-12345" + }, + { + "fieldDefId": "uuid", + "name": "segment", + "label": "Segment", + "fieldType": "DROPDOWN", + "value": "A" + }, + { + "fieldDefId": "uuid", + "name": "newsletter", + "label": "Newsletter", + "fieldType": "CHECKBOX", + "value": null + } + ] +} +``` + +Das Array enthaelt ALLE definierten Custom Fields fuer den jeweiligen Entity-Typ (auch ohne Wert = `value: null`), sortiert nach `position`. Das Frontend bekommt so immer die vollstaendige Feldliste fuer Formular-Rendering. + +### Value-Mapping nach Feldtyp + +| FieldType | JS-Typ | Beispiel | +|-----------|--------|---------| +| TEXT, TEXTAREA, URL | string | `"KD-12345"` | +| NUMBER | number | `42.5` | +| DATE | string (ISO) | `"2026-01-15T00:00:00.000Z"` | +| CHECKBOX | boolean | `true` | +| DROPDOWN | string (single select) | `"premium"` | +| MULTI_SELECT | string[] | `["tag-a", "tag-b"]` | + +### Wichtige Regeln + +- **name** (interner Slug) wird automatisch aus dem Label generiert (Umlaute: ae/oe/ue/ss). Unique pro Tenant + Entity-Typ. +- **entityType** und **fieldType** sind nach Erstellung **nicht mehr aenderbar** (wuerde bestehende Werte invalidieren). +- **DROPDOWN/MULTI_SELECT** erfordern `options`: `[{value: "a", label: "Segment A"}, ...]`. Bei Wert-Speicherung wird gegen die Options validiert. +- **DELETE** einer Definition loescht automatisch alle gespeicherten Werte (CASCADE). +- **Orphan-Cleanup**: Beim Loeschen eines Contacts/Company/Deal werden zugehoerige Custom-Field-Werte automatisch entfernt. +- **isRequired** wird aktuell nur client-seitig ausgewertet (das Flag wird mitgeliefert). Server-seitige Pflichtfeld-Pruefung kann spaeter ergaenzt werden. + +### TODO fuer Frontend + +1. **Admin-Bereich:** CRUD-UI fuer Custom Field Definitionen (pro Entity-Typ: PERSON, COMPANY, DEAL) + - Felder: Label (→ Slug auto), Feldtyp (immutable nach Erstellung), Options-Editor fuer DROPDOWN/MULTI_SELECT, Pflichtfeld-Toggle, Drag&Drop fuer Position +2. **Entity-Detail-Pages:** `customFields`-Array aus Response auslesen und dynamisch rendern + - Pro Feldtyp: Text-Input, Textarea, Number-Input, Date-Picker, Select, Multi-Select, Checkbox, URL-Input +3. **Entity-Formulare:** Custom Fields im Erstellen/Bearbeiten-Formular einbinden + - `PUT /custom-fields/:entityId/values` nach Entity-Speichern aufrufen + - `isRequired` client-seitig validieren +4. **API-Aufrufe:** `GET /custom-fields?entityType=PERSON` fuer Feld-Definitionen, `PUT/GET /custom-fields/:entityId/values` fuer Werte diff --git a/packages/crm-service/Summarize.md b/packages/crm-service/Summarize.md index bf8d565..91fc55c 100644 --- a/packages/crm-service/Summarize.md +++ b/packages/crm-service/Summarize.md @@ -26,11 +26,12 @@ packages/crm-service/ redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks) auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter, Shared DTOs (contact-info, owner) - companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push) - contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events) + custom-fields/ — Custom Fields System (Phase 2.1): Definitionen + Werte CRUD, Entity-Integration + companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push, Custom Fields) + contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events, Custom Fields) activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional) pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update) - deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen) events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe) @@ -73,6 +74,8 @@ packages/crm-service/ - **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter) - **LexwareVoucher** — Gecachte Belege aus Lexware Office - **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) +- **CustomFieldDef** — Benutzerdefinierte Feld-Definitionen (Phase 2.1): entityType, name (Slug), label, fieldType, options (JSONB), isRequired, position. Unique: [tenantId, entityType, name] +- **CustomFieldValue** — Gespeicherte Werte (Phase 2.1): fieldDefId, entityId (generisch), valueText/valueNumber/valueDate/valueBoolean/valueJson. Unique: [fieldDefId, entityId]. CASCADE Delete bei Definition-Loeschung ### Entity-Beziehungen @@ -94,6 +97,7 @@ PipelineStage (1) --< (n) Deal — stageId Deal (1) --< (n) DealVoucher — dealId (Cascade) LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId +CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade) ``` ### API-Endpunkte @@ -128,6 +132,14 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId | GET/POST | /api/v1/crm/deals | Liste / Erstellen | | GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete | | GET | /health | Health-Check (DB, Redis, Lexware) | +| **Custom Fields** | | | +| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen | +| GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) | +| GET | /api/v1/crm/custom-fields/:id | Definition abrufen | +| PATCH | /api/v1/crm/custom-fields/:id | Definition aktualisieren | +| DELETE | /api/v1/crm/custom-fields/:id | Definition loeschen (CASCADE) | +| PUT | /api/v1/crm/custom-fields/:entityId/values | Werte setzen (Bulk-Upsert) | +| GET | /api/v1/crm/custom-fields/:entityId/values | Werte lesen | | **Lexware Kontakte** | | | | GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen | | POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen | @@ -184,15 +196,14 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId - `20260310_add_lexware_integration` — Lexware Office Integration - `20260311_add_company_detail_overhaul` — Company Detail Overhaul - `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason +- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte) ### Naechste Schritte -1. Migration `20260312_phase1_schema_expansion` auf Server anwenden +1. Migration `20260312_phase2_custom_fields` auf Server anwenden 2. Container neu bauen und deployen -3. Frontend: Multi-Value Email/Phone UI implementieren -4. Frontend: Owner-Management UI -5. Frontend: EntityStatus statt isActive verwenden -6. Frontend: LostReason bei Deal-Verlust einblenden -7. Phase 2: Office 365 E-Mail Integration (Planer-Briefing vorhanden) -8. Phase 3: Kontakt-Zusammenfuehrung (Merge) -9. Phase 4: Aktivitaets-Erweiterung + Dashboard Widgets +3. Frontend: Custom Fields Admin-UI + Entity-Integration +4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard) +5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage) +6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment) +7. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter) diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index 8265882..eb4a2ed 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -717,6 +717,81 @@ model DealOwner { @@schema("app_crm") } +// -------------------------------------------------------- +// Phase 2.1 Enums — Custom Fields +// -------------------------------------------------------- +enum CustomFieldEntityType { + PERSON + COMPANY + DEAL + + @@schema("app_crm") +} + +enum CustomFieldType { + TEXT + TEXTAREA + NUMBER + DATE + DROPDOWN + MULTI_SELECT + CHECKBOX + URL + + @@schema("app_crm") +} + +// -------------------------------------------------------- +// CustomFieldDef - Benutzerdefinierte Feld-Definitionen (Phase 2.1) +// -------------------------------------------------------- +model CustomFieldDef { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + entityType CustomFieldEntityType @map("entity_type") + name String @db.VarChar(200) + label String @db.VarChar(200) + fieldType CustomFieldType @map("field_type") + options Json? @db.JsonB + isRequired Boolean @default(false) @map("is_required") + position Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + values CustomFieldValue[] + + @@unique([tenantId, entityType, name]) + @@index([tenantId]) + @@index([tenantId, entityType]) + @@map("crm_custom_field_defs") + @@schema("app_crm") +} + +// -------------------------------------------------------- +// CustomFieldValue - Benutzerdefinierte Feld-Werte (Phase 2.1) +// -------------------------------------------------------- +model CustomFieldValue { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + fieldDefId String @map("field_def_id") @db.Uuid + entityId String @map("entity_id") @db.Uuid + valueText String? @map("value_text") @db.Text + valueNumber Decimal? @map("value_number") @db.Decimal(15, 4) + valueDate DateTime? @map("value_date") + valueBoolean Boolean? @map("value_boolean") + valueJson Json? @map("value_json") @db.JsonB + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + fieldDef CustomFieldDef @relation(fields: [fieldDefId], references: [id], onDelete: Cascade) + + @@unique([fieldDefId, entityId]) + @@index([tenantId]) + @@index([tenantId, entityId]) + @@index([fieldDefId]) + @@map("crm_custom_field_values") + @@schema("app_crm") +} + // -------------------------------------------------------- // TradeEvent - Messe-/Event-Timer (admin-konfigurierbar) // -------------------------------------------------------- diff --git a/packages/crm-service/prisma/migrations/20260312_phase2_custom_fields/migration.sql b/packages/crm-service/prisma/migrations/20260312_phase2_custom_fields/migration.sql new file mode 100644 index 0000000..4284cdd --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260312_phase2_custom_fields/migration.sql @@ -0,0 +1,86 @@ +-- ============================================================ +-- Phase 2.1: Custom Fields System +-- Benutzerdefinierte Felder fuer PERSON, COMPANY, DEAL +-- ============================================================ + +-- -------------------------------------------------------- +-- 1. Neue Enums +-- -------------------------------------------------------- + +CREATE TYPE "app_crm"."CustomFieldEntityType" AS ENUM ( + 'PERSON', 'COMPANY', 'DEAL' +); + +CREATE TYPE "app_crm"."CustomFieldType" AS ENUM ( + 'TEXT', 'TEXTAREA', 'NUMBER', 'DATE', + 'DROPDOWN', 'MULTI_SELECT', 'CHECKBOX', 'URL' +); + +-- -------------------------------------------------------- +-- 2. crm_custom_field_defs — Feld-Definitionen +-- -------------------------------------------------------- + +CREATE TABLE "app_crm"."crm_custom_field_defs" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "entity_type" "app_crm"."CustomFieldEntityType" NOT NULL, + "name" VARCHAR(200) NOT NULL, + "label" VARCHAR(200) NOT NULL, + "field_type" "app_crm"."CustomFieldType" NOT NULL, + "options" JSONB, + "is_required" BOOLEAN NOT NULL DEFAULT false, + "position" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "crm_custom_field_defs_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "crm_custom_field_defs_tenant_entity_name_key" + ON "app_crm"."crm_custom_field_defs"("tenant_id", "entity_type", "name"); + +CREATE INDEX "crm_custom_field_defs_tenant_id_idx" + ON "app_crm"."crm_custom_field_defs"("tenant_id"); + +CREATE INDEX "crm_custom_field_defs_tenant_entity_idx" + ON "app_crm"."crm_custom_field_defs"("tenant_id", "entity_type"); + +-- -------------------------------------------------------- +-- 3. crm_custom_field_values — Feld-Werte +-- -------------------------------------------------------- + +CREATE TABLE "app_crm"."crm_custom_field_values" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "field_def_id" UUID NOT NULL, + "entity_id" UUID NOT NULL, + "value_text" TEXT, + "value_number" DECIMAL(15, 4), + "value_date" TIMESTAMP(3), + "value_boolean" BOOLEAN, + "value_json" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "crm_custom_field_values_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "crm_custom_field_values_field_entity_key" + ON "app_crm"."crm_custom_field_values"("field_def_id", "entity_id"); + +CREATE INDEX "crm_custom_field_values_tenant_id_idx" + ON "app_crm"."crm_custom_field_values"("tenant_id"); + +CREATE INDEX "crm_custom_field_values_tenant_entity_idx" + ON "app_crm"."crm_custom_field_values"("tenant_id", "entity_id"); + +CREATE INDEX "crm_custom_field_values_field_def_id_idx" + ON "app_crm"."crm_custom_field_values"("field_def_id"); + +-- -------------------------------------------------------- +-- 4. Foreign Key: Values -> Definitions (CASCADE) +-- -------------------------------------------------------- + +ALTER TABLE "app_crm"."crm_custom_field_values" + ADD CONSTRAINT "crm_custom_field_values_field_def_fkey" + FOREIGN KEY ("field_def_id") + REFERENCES "app_crm"."crm_custom_field_defs"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index b04f426..3c6c471 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -21,6 +21,7 @@ import { RelationshipTypesModule } from './relationship-types/relationship-types import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module'; import { TradeEventsModule } from './trade-events/trade-events.module'; import { CrmEventsModule } from './events/crm-events.module'; +import { CustomFieldsModule } from './custom-fields/custom-fields.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { CrmEventsModule } from './events/crm-events.module'; CompanyRelationshipsModule, TradeEventsModule, CrmEventsModule, + CustomFieldsModule, ], providers: [ { diff --git a/packages/crm-service/src/companies/companies.module.ts b/packages/crm-service/src/companies/companies.module.ts index dab6c27..01c6939 100644 --- a/packages/crm-service/src/companies/companies.module.ts +++ b/packages/crm-service/src/companies/companies.module.ts @@ -4,9 +4,10 @@ import { CompaniesService } from './companies.service'; import { CrmPrismaModule } from '../prisma/crm-prisma.module'; import { LexwareModule } from '../lexware/lexware.module'; import { OwnersModule } from '../owners/owners.module'; +import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; @Module({ - imports: [CrmPrismaModule, LexwareModule, OwnersModule], + imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule], controllers: [CompaniesController], providers: [CompaniesService], exports: [CompaniesService], diff --git a/packages/crm-service/src/companies/companies.service.ts b/packages/crm-service/src/companies/companies.service.ts index b117356..239ce4b 100644 --- a/packages/crm-service/src/companies/companies.service.ts +++ b/packages/crm-service/src/companies/companies.service.ts @@ -4,6 +4,8 @@ import { CreateCompanyDto } from './dto/create-company.dto'; import { UpdateCompanyDto } from './dto/update-company.dto'; import { QueryCompaniesDto } from './dto/query-companies.dto'; import { LexwareContactsService } from '../lexware/lexware-contacts.service'; +import { CustomFieldsService } from '../custom-fields/custom-fields.service'; +import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; import { Prisma } from '.prisma/crm-client'; import { EntityStatus } from '../common/dto/contact-info.dto'; @@ -14,6 +16,7 @@ export class CompaniesService { constructor( private readonly prisma: CrmPrismaService, private readonly lexwareContacts: LexwareContactsService, + private readonly customFieldsService: CustomFieldsService, ) {} async create(tenantId: string, userId: string, dto: CreateCompanyDto) { @@ -227,7 +230,14 @@ export class CompaniesService { throw new NotFoundException('Unternehmen nicht gefunden'); } - return company; + // Custom Fields anhaengen + const customFields = await this.customFieldsService.getCustomFieldsForEntity( + tenantId, + CustomFieldEntityType.COMPANY, + id, + ); + + return { ...company, customFields }; } async update( @@ -339,6 +349,11 @@ export class CompaniesService { async remove(tenantId: string, id: string) { await this.findOne(tenantId, id); + // Custom Field Values entfernen (entityId hat keinen FK, daher manuell) + await this.prisma.customFieldValue.deleteMany({ + where: { tenantId, entityId: id }, + }); + return this.prisma.company.delete({ where: { id } }); } } diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts index 16941e7..81bc8da 100644 --- a/packages/crm-service/src/contacts/contacts.module.ts +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -3,9 +3,10 @@ import { ContactsController } from './contacts.controller'; import { ContactsService } from './contacts.service'; import { LexwareModule } from '../lexware/lexware.module'; import { OwnersModule } from '../owners/owners.module'; +import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; @Module({ - imports: [LexwareModule, OwnersModule], + imports: [LexwareModule, OwnersModule, CustomFieldsModule], controllers: [ContactsController], providers: [ContactsService], exports: [ContactsService], diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts index 3133ee7..7ed6f3c 100644 --- a/packages/crm-service/src/contacts/contacts.service.ts +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -5,6 +5,8 @@ import { UpdateContactDto } from './dto/update-contact.dto'; import { QueryContactsDto } from './dto/query-contacts.dto'; import { LexwareContactsService } from '../lexware/lexware-contacts.service'; import { CrmEventPublisher } from '../events/crm-event-publisher.service'; +import { CustomFieldsService } from '../custom-fields/custom-fields.service'; +import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; import { Prisma } from '.prisma/crm-client'; import { EntityStatus } from '../common/dto/contact-info.dto'; @@ -16,6 +18,7 @@ export class ContactsService { private readonly prisma: CrmPrismaService, private readonly lexwareContacts: LexwareContactsService, private readonly eventPublisher: CrmEventPublisher, + private readonly customFieldsService: CustomFieldsService, ) {} async create(tenantId: string, userId: string, dto: CreateContactDto) { @@ -186,7 +189,14 @@ export class ContactsService { throw new NotFoundException('Kontakt nicht gefunden'); } - return contact; + // Custom Fields anhaengen + const customFields = await this.customFieldsService.getCustomFieldsForEntity( + tenantId, + CustomFieldEntityType.PERSON, + id, + ); + + return { ...contact, customFields }; } async update( @@ -305,6 +315,11 @@ export class ContactsService { async remove(tenantId: string, id: string) { await this.findOne(tenantId, id); + // Custom Field Values entfernen (entityId hat keinen FK, daher manuell) + await this.prisma.customFieldValue.deleteMany({ + where: { tenantId, entityId: id }, + }); + return this.prisma.contact.delete({ where: { id } }); } } diff --git a/packages/crm-service/src/custom-fields/custom-fields.controller.ts b/packages/crm-service/src/custom-fields/custom-fields.controller.ts new file mode 100644 index 0000000..59075f9 --- /dev/null +++ b/packages/crm-service/src/custom-fields/custom-fields.controller.ts @@ -0,0 +1,156 @@ +// ============================================================ +// CustomFieldsController — REST-Endpoints fuer Custom Fields +// ============================================================ + +import { + Controller, + Get, + Post, + Patch, + Put, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { CustomFieldsService } from './custom-fields.service'; +import { CreateCustomFieldDto, CustomFieldEntityType } from './dto/create-custom-field.dto'; +import { UpdateCustomFieldDto } from './dto/update-custom-field.dto'; +import { SetCustomFieldValuesDto } from './dto/set-custom-field-values.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('Custom Fields') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('custom-fields') +export class CustomFieldsController { + constructor(private readonly customFieldsService: CustomFieldsService) {} + + // -------------------------------------------------------- + // Definitionen + // -------------------------------------------------------- + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Feld-Definition erstellen' }) + async createDefinition( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateCustomFieldDto, + ) { + const definition = await this.customFieldsService.createDefinition( + user.tenantId!, + dto, + ); + return singleResponse(definition); + } + + @Get() + @ApiOperation({ summary: 'Alle Feld-Definitionen auflisten' }) + @ApiQuery({ + name: 'entityType', + required: false, + enum: CustomFieldEntityType, + description: 'Filter nach Entity-Typ', + }) + async findAllDefinitions( + @CurrentUser() user: JwtPayload, + @Query('entityType') entityType?: CustomFieldEntityType, + ) { + const definitions = await this.customFieldsService.findAllDefinitions( + user.tenantId!, + entityType, + ); + return { data: definitions }; + } + + @Get(':id') + @ApiOperation({ summary: 'Feld-Definition abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOneDefinition( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const definition = await this.customFieldsService.findOneDefinition( + user.tenantId!, + id, + ); + return singleResponse(definition); + } + + @Patch(':id') + @ApiOperation({ summary: 'Feld-Definition aktualisieren (Label, Options, Position, Required)' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async updateDefinition( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomFieldDto, + ) { + const definition = await this.customFieldsService.updateDefinition( + user.tenantId!, + id, + dto, + ); + return singleResponse(definition); + } + + @Delete(':id') + @ApiOperation({ summary: 'Feld-Definition loeschen (CASCADE auf alle Werte!)' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async removeDefinition( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const definition = await this.customFieldsService.removeDefinition( + user.tenantId!, + id, + ); + return singleResponse(definition); + } + + // -------------------------------------------------------- + // Werte + // -------------------------------------------------------- + + @Put(':entityId/values') + @ApiOperation({ summary: 'Custom-Field-Werte fuer Entity setzen (Bulk-Upsert)' }) + @ApiParam({ name: 'entityId', type: 'string', format: 'uuid', description: 'Contact/Company/Deal ID' }) + async setValues( + @CurrentUser() user: JwtPayload, + @Param('entityId', ParseUUIDPipe) entityId: string, + @Body() dto: SetCustomFieldValuesDto, + ) { + const result = await this.customFieldsService.setValues( + user.tenantId!, + entityId, + dto, + ); + return singleResponse(result); + } + + @Get(':entityId/values') + @ApiOperation({ summary: 'Custom-Field-Werte fuer Entity lesen' }) + @ApiParam({ name: 'entityId', type: 'string', format: 'uuid', description: 'Contact/Company/Deal ID' }) + async getValues( + @CurrentUser() user: JwtPayload, + @Param('entityId', ParseUUIDPipe) entityId: string, + ) { + const values = await this.customFieldsService.getValues( + user.tenantId!, + entityId, + ); + return { data: values }; + } +} diff --git a/packages/crm-service/src/custom-fields/custom-fields.module.ts b/packages/crm-service/src/custom-fields/custom-fields.module.ts new file mode 100644 index 0000000..36cca7c --- /dev/null +++ b/packages/crm-service/src/custom-fields/custom-fields.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CustomFieldsController } from './custom-fields.controller'; +import { CustomFieldsService } from './custom-fields.service'; + +@Module({ + controllers: [CustomFieldsController], + providers: [CustomFieldsService], + exports: [CustomFieldsService], +}) +export class CustomFieldsModule {} diff --git a/packages/crm-service/src/custom-fields/custom-fields.service.ts b/packages/crm-service/src/custom-fields/custom-fields.service.ts new file mode 100644 index 0000000..a504a14 --- /dev/null +++ b/packages/crm-service/src/custom-fields/custom-fields.service.ts @@ -0,0 +1,577 @@ +// ============================================================ +// CustomFieldsService — CRUD fuer Definitionen + Werte +// ============================================================ + +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { Prisma } from '.prisma/crm-client'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { + CreateCustomFieldDto, + CustomFieldEntityType, + CustomFieldType, +} from './dto/create-custom-field.dto'; +import { UpdateCustomFieldDto } from './dto/update-custom-field.dto'; +import { SetCustomFieldValuesDto } from './dto/set-custom-field-values.dto'; + +// -------------------------------------------------------- +// Response-Interface fuer Entity-Detail-Integration +// -------------------------------------------------------- +export interface CustomFieldEntry { + fieldDefId: string; + name: string; + label: string; + fieldType: string; + value: string | number | boolean | string[] | null; +} + +@Injectable() +export class CustomFieldsService { + private readonly logger = new Logger(CustomFieldsService.name); + + constructor(private readonly prisma: CrmPrismaService) {} + + // ============================================================ + // Slug-Generierung + // ============================================================ + + /** + * Generiert einen DB-sicheren Slug aus dem Label. + * "Kundennummer" -> "kundennummer" + * "Messe / Event" -> "messe-event" + * "USt-IdNr." -> "ust-idnr" + * Umlaute: ae/oe/ue/ss + */ + private slugify(label: string): string { + return label + .toLowerCase() + .trim() + .replace(/[äÄ]/g, 'ae') + .replace(/[öÖ]/g, 'oe') + .replace(/[üÜ]/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); + } + + // ============================================================ + // CRUD — Feld-Definitionen + // ============================================================ + + async createDefinition(tenantId: string, dto: CreateCustomFieldDto) { + // Options-Validierung + this.validateOptionsForFieldType(dto.fieldType, dto.options); + + const name = this.slugify(dto.label); + if (!name) { + throw new BadRequestException('Label ergibt keinen gueltigen internen Namen'); + } + + // Uniqueness pruefen + const existing = await this.prisma.customFieldDef.findUnique({ + where: { + tenantId_entityType_name: { + tenantId, + entityType: dto.entityType, + name, + }, + }, + }); + if (existing) { + throw new ConflictException( + `Ein Feld mit dem Namen "${name}" existiert bereits fuer ${dto.entityType}`, + ); + } + + return this.prisma.customFieldDef.create({ + data: { + tenantId, + entityType: dto.entityType, + name, + label: dto.label, + fieldType: dto.fieldType, + options: dto.options ? (dto.options as unknown as Prisma.InputJsonValue) : undefined, + isRequired: dto.isRequired ?? false, + position: dto.position ?? 0, + }, + }); + } + + async findAllDefinitions(tenantId: string, entityType?: CustomFieldEntityType) { + const where: Prisma.CustomFieldDefWhereInput = { tenantId }; + + if (entityType) { + where.entityType = entityType; + } + + return this.prisma.customFieldDef.findMany({ + where, + orderBy: [{ position: 'asc' }, { label: 'asc' }], + include: { + _count: { select: { values: true } }, + }, + }); + } + + async findOneDefinition(tenantId: string, id: string) { + const def = await this.prisma.customFieldDef.findFirst({ + where: { id, tenantId }, + include: { + _count: { select: { values: true } }, + }, + }); + + if (!def) { + throw new NotFoundException('Feld-Definition nicht gefunden'); + } + + return def; + } + + async updateDefinition(tenantId: string, id: string, dto: UpdateCustomFieldDto) { + const existing = await this.findOneDefinition(tenantId, id); + + // Options nur fuer DROPDOWN/MULTI_SELECT erlauben + if (dto.options !== undefined) { + const isSelectType = + existing.fieldType === CustomFieldType.DROPDOWN || + existing.fieldType === CustomFieldType.MULTI_SELECT; + + if (!isSelectType) { + throw new BadRequestException( + 'options darf nur fuer DROPDOWN/MULTI_SELECT Felder gesetzt werden', + ); + } + + if (dto.options.length === 0) { + throw new BadRequestException( + 'options darf nicht leer sein fuer DROPDOWN/MULTI_SELECT', + ); + } + } + + // Label-Aenderung → Slug neu berechnen + Unique-Check + let name: string | undefined; + if (dto.label && dto.label !== existing.label) { + name = this.slugify(dto.label); + if (!name) { + throw new BadRequestException('Label ergibt keinen gueltigen internen Namen'); + } + + if (name !== existing.name) { + const duplicate = await this.prisma.customFieldDef.findFirst({ + where: { + tenantId, + entityType: existing.entityType, + name, + NOT: { id }, + }, + }); + if (duplicate) { + throw new ConflictException( + `Ein Feld mit dem Namen "${name}" existiert bereits fuer ${existing.entityType}`, + ); + } + } + } + + const updateData: Prisma.CustomFieldDefUpdateInput = {}; + if (dto.label !== undefined) updateData.label = dto.label; + if (name !== undefined) updateData.name = name; + if (dto.options !== undefined) updateData.options = dto.options as unknown as Prisma.InputJsonValue; + if (dto.isRequired !== undefined) updateData.isRequired = dto.isRequired; + if (dto.position !== undefined) updateData.position = dto.position; + + return this.prisma.customFieldDef.update({ + where: { id }, + data: updateData, + include: { + _count: { select: { values: true } }, + }, + }); + } + + async removeDefinition(tenantId: string, id: string) { + await this.findOneDefinition(tenantId, id); + + // Prisma Cascade loescht automatisch alle zugehoerigen Values + return this.prisma.customFieldDef.delete({ + where: { id }, + }); + } + + // ============================================================ + // Werte — Bulk-Upsert + Lesen + // ============================================================ + + async setValues(tenantId: string, entityId: string, dto: SetCustomFieldValuesDto) { + if (dto.values.length === 0) { + return []; + } + + // 1. Alle referenzierten Definitionen in einer Query laden + const fieldDefIds = dto.values.map((v) => v.fieldDefId); + const fieldDefs = await this.prisma.customFieldDef.findMany({ + where: { + id: { in: fieldDefIds }, + tenantId, + }, + }); + + const defMap = new Map(fieldDefs.map((d) => [d.id, d])); + + // 2. Validierung: Alle fieldDefIds muessen existieren + Typ-Check + for (const item of dto.values) { + const def = defMap.get(item.fieldDefId); + if (!def) { + throw new NotFoundException( + `Feld-Definition "${item.fieldDefId}" nicht gefunden`, + ); + } + this.validateValue(def.fieldType as CustomFieldType, def.label, def.options, item.value); + } + + // 3. Upsert in Transaction + return this.prisma.$transaction(async (tx) => { + const results: Array<{ + fieldDefId: string; + entityId: string; + value: string | number | boolean | string[] | null; + }> = []; + + for (const item of dto.values) { + const def = defMap.get(item.fieldDefId)!; + + if (item.value === null) { + // null = Wert loeschen + await tx.customFieldValue.deleteMany({ + where: { fieldDefId: item.fieldDefId, entityId }, + }); + results.push({ fieldDefId: item.fieldDefId, entityId, value: null }); + } else { + const columns = this.mapValueToColumns(def.fieldType as CustomFieldType, item.value); + + await tx.customFieldValue.upsert({ + where: { + fieldDefId_entityId: { + fieldDefId: item.fieldDefId, + entityId, + }, + }, + update: { + ...columns, + }, + create: { + tenantId, + fieldDefId: item.fieldDefId, + entityId, + ...columns, + }, + }); + results.push({ fieldDefId: item.fieldDefId, entityId, value: item.value }); + } + } + + return results; + }); + } + + async getValues(tenantId: string, entityId: string) { + const values = await this.prisma.customFieldValue.findMany({ + where: { tenantId, entityId }, + include: { + fieldDef: { + select: { + id: true, + name: true, + label: true, + fieldType: true, + entityType: true, + options: true, + isRequired: true, + position: true, + }, + }, + }, + orderBy: { fieldDef: { position: 'asc' } }, + }); + + return values.map((v) => ({ + fieldDefId: v.fieldDef.id, + name: v.fieldDef.name, + label: v.fieldDef.label, + fieldType: v.fieldDef.fieldType, + isRequired: v.fieldDef.isRequired, + value: this.extractValue(v.fieldDef.fieldType as CustomFieldType, v), + })); + } + + // ============================================================ + // Integration-Helper fuer Entity-Detail-Responses + // ============================================================ + + /** + * Liefert ALLE definierten Custom Fields fuer einen Entity-Typ, + * zusammen mit den gespeicherten Werten (oder null). + * Wird in contacts/companies/deals findOne() aufgerufen. + */ + async getCustomFieldsForEntity( + tenantId: string, + entityType: CustomFieldEntityType, + entityId: string, + ): Promise { + // Alle Definitionen fuer diesen Entity-Typ laden + const fieldDefs = await this.prisma.customFieldDef.findMany({ + where: { tenantId, entityType }, + orderBy: { position: 'asc' }, + }); + + if (fieldDefs.length === 0) return []; + + // Alle Werte fuer diese Entity laden + const values = await this.prisma.customFieldValue.findMany({ + where: { + tenantId, + entityId, + fieldDefId: { in: fieldDefs.map((d) => d.id) }, + }, + }); + + const valueMap = new Map(values.map((v) => [v.fieldDefId, v])); + + // Merge: ALLE Definitionen mit Wert (oder null) + return fieldDefs.map((def) => { + const valueRow = valueMap.get(def.id); + return { + fieldDefId: def.id, + name: def.name, + label: def.label, + fieldType: def.fieldType, + value: valueRow + ? this.extractValue(def.fieldType as CustomFieldType, valueRow) + : null, + }; + }); + } + + // ============================================================ + // Value-Mapping: fieldType → Datenbank-Spalte + // ============================================================ + + private mapValueToColumns( + fieldType: CustomFieldType, + value: string | number | boolean | string[], + ): { + valueText: string | null; + valueNumber: Prisma.Decimal | number | null; + valueDate: Date | null; + valueBoolean: boolean | null; + valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue; + } { + const result: { + valueText: string | null; + valueNumber: Prisma.Decimal | number | null; + valueDate: Date | null; + valueBoolean: boolean | null; + valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue; + } = { + valueText: null, + valueNumber: null, + valueDate: null, + valueBoolean: null, + valueJson: Prisma.DbNull, // Prisma erfordert DbNull statt null fuer JSON-Felder + }; + + switch (fieldType) { + case CustomFieldType.TEXT: + case CustomFieldType.TEXTAREA: + case CustomFieldType.URL: + case CustomFieldType.DROPDOWN: + result.valueText = String(value); + break; + + case CustomFieldType.NUMBER: + result.valueNumber = Number(value); + break; + + case CustomFieldType.DATE: + result.valueDate = new Date(value as string); + break; + + case CustomFieldType.CHECKBOX: + result.valueBoolean = Boolean(value); + break; + + case CustomFieldType.MULTI_SELECT: + result.valueJson = value as Prisma.InputJsonValue; + break; + } + + return result; + } + + // ============================================================ + // Value-Extraktion: Datenbank-Spalte → JS-Wert + // ============================================================ + + private extractValue( + fieldType: CustomFieldType, + row: { + valueText: string | null; + valueNumber: Prisma.Decimal | null; + valueDate: Date | null; + valueBoolean: boolean | null; + valueJson: Prisma.JsonValue; + }, + ): string | number | boolean | string[] | null { + switch (fieldType) { + case CustomFieldType.TEXT: + case CustomFieldType.TEXTAREA: + case CustomFieldType.URL: + case CustomFieldType.DROPDOWN: + return row.valueText; + + case CustomFieldType.NUMBER: + return row.valueNumber !== null + ? Number(row.valueNumber) + : null; + + case CustomFieldType.DATE: + return row.valueDate + ? row.valueDate.toISOString() + : null; + + case CustomFieldType.CHECKBOX: + return row.valueBoolean; + + case CustomFieldType.MULTI_SELECT: + return (row.valueJson as string[] | null) ?? null; + + default: + return row.valueText; + } + } + + // ============================================================ + // Validierung + // ============================================================ + + /** + * Prueft ob options fuer den gegebenen Feldtyp korrekt sind. + */ + private validateOptionsForFieldType( + fieldType: CustomFieldType, + options?: Array<{ value: string; label: string }>, + ): void { + const isSelectType = + fieldType === CustomFieldType.DROPDOWN || + fieldType === CustomFieldType.MULTI_SELECT; + + if (isSelectType && (!options || options.length === 0)) { + throw new BadRequestException( + 'options ist Pflicht fuer DROPDOWN und MULTI_SELECT Felder', + ); + } + + if (!isSelectType && options && options.length > 0) { + throw new BadRequestException( + 'options darf nur fuer DROPDOWN/MULTI_SELECT gesetzt werden', + ); + } + } + + /** + * Prueft ob ein Wert zum Feldtyp passt. + */ + private validateValue( + fieldType: CustomFieldType, + label: string, + options: Prisma.JsonValue, + value: string | number | boolean | string[] | null, + ): void { + // null ist immer erlaubt (Feld leeren) + if (value === null) return; + + switch (fieldType) { + case CustomFieldType.TEXT: + case CustomFieldType.TEXTAREA: + case CustomFieldType.URL: + if (typeof value !== 'string') { + throw new BadRequestException( + `Feld "${label}" erwartet einen String-Wert`, + ); + } + break; + + case CustomFieldType.NUMBER: + if (typeof value !== 'number' && typeof value !== 'string') { + throw new BadRequestException( + `Feld "${label}" erwartet einen numerischen Wert`, + ); + } + if (isNaN(Number(value))) { + throw new BadRequestException( + `Feld "${label}": "${String(value)}" ist kein gueltiger numerischer Wert`, + ); + } + break; + + case CustomFieldType.DATE: + if (typeof value !== 'string' || isNaN(Date.parse(value))) { + throw new BadRequestException( + `Feld "${label}" erwartet ein gueltiges Datum (ISO 8601)`, + ); + } + break; + + case CustomFieldType.CHECKBOX: + if (typeof value !== 'boolean') { + throw new BadRequestException( + `Feld "${label}" erwartet einen Boolean-Wert`, + ); + } + break; + + case CustomFieldType.DROPDOWN: { + if (typeof value !== 'string') { + throw new BadRequestException( + `Feld "${label}" erwartet einen String-Wert (Auswahl)`, + ); + } + const opts = options as Array<{ value: string; label: string }> | null; + if (opts && !opts.some((o) => o.value === value)) { + throw new BadRequestException( + `Feld "${label}": "${value}" ist keine gueltige Option`, + ); + } + break; + } + + case CustomFieldType.MULTI_SELECT: { + if (!Array.isArray(value)) { + throw new BadRequestException( + `Feld "${label}" erwartet ein Array von Werten`, + ); + } + const msOpts = options as Array<{ value: string; label: string }> | null; + if (msOpts) { + const validValues = new Set(msOpts.map((o) => o.value)); + for (const v of value) { + if (!validValues.has(v)) { + throw new BadRequestException( + `Feld "${label}": "${v}" ist keine gueltige Option`, + ); + } + } + } + break; + } + } + } +} diff --git a/packages/crm-service/src/custom-fields/dto/create-custom-field.dto.ts b/packages/crm-service/src/custom-fields/dto/create-custom-field.dto.ts new file mode 100644 index 0000000..48b2764 --- /dev/null +++ b/packages/crm-service/src/custom-fields/dto/create-custom-field.dto.ts @@ -0,0 +1,105 @@ +// ============================================================ +// DTOs fuer Custom Fields — Feld-Definitionen erstellen +// ============================================================ + +import { + IsString, + IsEnum, + IsOptional, + IsBoolean, + IsInt, + IsArray, + ValidateNested, + MinLength, + MaxLength, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +// -------------------------------------------------------- +// Enums (TypeScript-Pendants zu Prisma Enums) +// -------------------------------------------------------- + +export enum CustomFieldEntityType { + PERSON = 'PERSON', + COMPANY = 'COMPANY', + DEAL = 'DEAL', +} + +export enum CustomFieldType { + TEXT = 'TEXT', + TEXTAREA = 'TEXTAREA', + NUMBER = 'NUMBER', + DATE = 'DATE', + DROPDOWN = 'DROPDOWN', + MULTI_SELECT = 'MULTI_SELECT', + CHECKBOX = 'CHECKBOX', + URL = 'URL', +} + +// -------------------------------------------------------- +// Option-DTO (fuer DROPDOWN / MULTI_SELECT) +// -------------------------------------------------------- + +export class CustomFieldOptionDto { + @ApiProperty({ description: 'Interner Wert (wird gespeichert)' }) + @IsString() + @MinLength(1) + value!: string; + + @ApiProperty({ description: 'Anzeigename im UI' }) + @IsString() + @MinLength(1) + label!: string; +} + +// -------------------------------------------------------- +// CreateCustomFieldDto +// -------------------------------------------------------- + +export class CreateCustomFieldDto { + @ApiProperty({ + enum: CustomFieldEntityType, + description: 'Entity-Typ, fuer den das Feld gilt', + }) + @IsEnum(CustomFieldEntityType) + entityType!: CustomFieldEntityType; + + @ApiProperty({ + maxLength: 200, + description: 'Anzeigename des Feldes (daraus wird der interne Name/Slug generiert)', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + label!: string; + + @ApiProperty({ + enum: CustomFieldType, + description: 'Feldtyp (nach Erstellung nicht mehr aenderbar)', + }) + @IsEnum(CustomFieldType) + fieldType!: CustomFieldType; + + @ApiPropertyOptional({ + type: [CustomFieldOptionDto], + description: 'Auswahloptionen — Pflicht fuer DROPDOWN und MULTI_SELECT', + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CustomFieldOptionDto) + options?: CustomFieldOptionDto[]; + + @ApiPropertyOptional({ default: false, description: 'Pflichtfeld?' }) + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @ApiPropertyOptional({ default: 0, description: 'Sortierposition im Formular' }) + @IsOptional() + @IsInt() + @Min(0) + position?: number; +} diff --git a/packages/crm-service/src/custom-fields/dto/set-custom-field-values.dto.ts b/packages/crm-service/src/custom-fields/dto/set-custom-field-values.dto.ts new file mode 100644 index 0000000..f51384f --- /dev/null +++ b/packages/crm-service/src/custom-fields/dto/set-custom-field-values.dto.ts @@ -0,0 +1,35 @@ +// ============================================================ +// DTO fuer Custom Fields — Werte setzen (Bulk-Upsert) +// ============================================================ + +import { IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class CustomFieldValueItemDto { + @ApiProperty({ format: 'uuid', description: 'ID der Feld-Definition' }) + @IsUUID() + fieldDefId!: string; + + @ApiProperty({ + description: 'Wert: string | number | boolean | string[] | null', + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array', items: { type: 'string' } }, + { nullable: true }, + ], + }) + // HINWEIS: Keine class-validator Dekoration fuer value, da Union-Type. + // Validierung gegen fieldType erfolgt im Service. + value!: string | number | boolean | string[] | null; +} + +export class SetCustomFieldValuesDto { + @ApiProperty({ type: [CustomFieldValueItemDto], description: 'Alle Werte fuer eine Entity' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CustomFieldValueItemDto) + values!: CustomFieldValueItemDto[]; +} diff --git a/packages/crm-service/src/custom-fields/dto/update-custom-field.dto.ts b/packages/crm-service/src/custom-fields/dto/update-custom-field.dto.ts new file mode 100644 index 0000000..0e092aa --- /dev/null +++ b/packages/crm-service/src/custom-fields/dto/update-custom-field.dto.ts @@ -0,0 +1,51 @@ +// ============================================================ +// DTO fuer Custom Fields — Feld-Definition aktualisieren +// ============================================================ +// HINWEIS: entityType und fieldType sind NICHT aenderbar, +// da eine Typ-Aenderung bestehende Werte invalidieren wuerde. +// ============================================================ + +import { + IsString, + IsOptional, + IsBoolean, + IsInt, + IsArray, + ValidateNested, + MinLength, + MaxLength, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { CustomFieldOptionDto } from './create-custom-field.dto'; + +export class UpdateCustomFieldDto { + @ApiPropertyOptional({ maxLength: 200, description: 'Neuer Anzeigename (Slug wird aktualisiert)' }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(200) + label?: string; + + @ApiPropertyOptional({ + type: [CustomFieldOptionDto], + description: 'Neue Auswahloptionen (nur fuer DROPDOWN/MULTI_SELECT)', + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CustomFieldOptionDto) + options?: CustomFieldOptionDto[]; + + @ApiPropertyOptional({ description: 'Pflichtfeld?' }) + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @ApiPropertyOptional({ description: 'Sortierposition im Formular' }) + @IsOptional() + @IsInt() + @Min(0) + position?: number; +} diff --git a/packages/crm-service/src/deals/deals.module.ts b/packages/crm-service/src/deals/deals.module.ts index 6e4f255..930868b 100644 --- a/packages/crm-service/src/deals/deals.module.ts +++ b/packages/crm-service/src/deals/deals.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { DealsController } from './deals.controller'; import { DealsService } from './deals.service'; import { OwnersModule } from '../owners/owners.module'; +import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; @Module({ - imports: [OwnersModule], + imports: [OwnersModule, CustomFieldsModule], controllers: [DealsController], providers: [DealsService], exports: [DealsService], diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index 3516378..c828c2e 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -8,6 +8,8 @@ import { CreateDealDto } from './dto/create-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto'; import { QueryDealsDto } from './dto/query-deals.dto'; import { CrmEventPublisher } from '../events/crm-event-publisher.service'; +import { CustomFieldsService } from '../custom-fields/custom-fields.service'; +import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; import { Prisma } from '.prisma/crm-client'; @Injectable() @@ -15,6 +17,7 @@ export class DealsService { constructor( private readonly prisma: CrmPrismaService, private readonly eventPublisher: CrmEventPublisher, + private readonly customFieldsService: CustomFieldsService, ) {} async create(tenantId: string, userId: string, dto: CreateDealDto) { @@ -204,7 +207,14 @@ export class DealsService { throw new NotFoundException('Vorgang nicht gefunden'); } - return deal; + // Custom Fields anhaengen + const customFields = await this.customFieldsService.getCustomFieldsForEntity( + tenantId, + CustomFieldEntityType.DEAL, + id, + ); + + return { ...deal, customFields }; } async update( @@ -315,6 +325,12 @@ export class DealsService { async remove(tenantId: string, id: string) { await this.findOne(tenantId, id); + + // Custom Field Values entfernen (entityId hat keinen FK, daher manuell) + await this.prisma.customFieldValue.deleteMany({ + where: { tenantId, entityId: id }, + }); + return this.prisma.deal.delete({ where: { id } }); } } diff --git a/packages/frontend/src/crm/CustomFieldsDisplay.tsx b/packages/frontend/src/crm/CustomFieldsDisplay.tsx new file mode 100644 index 0000000..78f218b --- /dev/null +++ b/packages/frontend/src/crm/CustomFieldsDisplay.tsx @@ -0,0 +1,144 @@ +// ============================================================ +// CustomFieldsDisplay — zeigt Custom Fields in Entity-Detail-Pages +// ============================================================ + +import type { CustomFieldValue } from './types'; + +interface CustomFieldsDisplayProps { + fields: CustomFieldValue[]; +} + +const labelStyle: React.CSSProperties = { + fontSize: '0.8125rem', + color: 'var(--color-text-muted)', + minWidth: 120, +}; + +const valueStyle: React.CSSProperties = { + fontSize: '0.875rem', + color: 'var(--color-text)', + wordBreak: 'break-word', +}; + +function formatValue(field: CustomFieldValue): React.ReactNode { + if (field.value === null || field.value === undefined) return null; + + switch (field.fieldType) { + case 'CHECKBOX': + return field.value ? 'Ja' : 'Nein'; + + case 'DATE': + if (typeof field.value === 'string') { + try { + return new Date(field.value).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } catch { + return String(field.value); + } + } + return String(field.value); + + case 'URL': + if (typeof field.value === 'string' && field.value) { + return ( + + {field.value} + + ); + } + return String(field.value); + + case 'MULTI_SELECT': + if (Array.isArray(field.value)) { + return ( + + {(field.value as string[]).map((v) => ( + + {v} + + ))} + + ); + } + return String(field.value); + + case 'NUMBER': + return typeof field.value === 'number' + ? field.value.toLocaleString('de-DE') + : String(field.value); + + case 'TEXTAREA': + return ( + + {String(field.value)} + + ); + + default: + return String(field.value); + } +} + +export function CustomFieldsDisplay({ fields }: CustomFieldsDisplayProps) { + // Nur Felder mit Wert anzeigen + const fieldsWithValues = fields.filter((f) => { + if (f.value === null || f.value === undefined) return false; + if (typeof f.value === 'string' && f.value === '') return false; + if (Array.isArray(f.value) && f.value.length === 0) return false; + return true; + }); + + if (fieldsWithValues.length === 0) return null; + + return ( +
+ + Zusatzfelder + +
+ {fieldsWithValues.map((field) => ( +
+ {field.label} + {formatValue(field)} +
+ ))} +
+
+ ); +} diff --git a/packages/frontend/src/crm/CustomFieldsForm.tsx b/packages/frontend/src/crm/CustomFieldsForm.tsx new file mode 100644 index 0000000..fd5e708 --- /dev/null +++ b/packages/frontend/src/crm/CustomFieldsForm.tsx @@ -0,0 +1,319 @@ +// ============================================================ +// CustomFieldsForm — Custom Fields im Entity-Formular bearbeiten +// Wird in ContactFormModal, CompanyFormModal, DealFormModal eingebettet +// ============================================================ + +import { useState, useEffect, useCallback } from 'react'; +import type { CustomFieldValue } from './types'; + +interface CustomFieldsFormProps { + /** customFields-Array aus der Entity-Detail-Response (alle definierten Felder) */ + fields: CustomFieldValue[]; + /** Callback bei Wertaenderung — liefert aktualisierte Map fieldDefId → value */ + onChange: (values: Record) => void; +} + +const labelStyle: React.CSSProperties = { + fontSize: '0.875rem', + fontWeight: 500, + color: 'var(--color-text)', + marginBottom: '0.25rem', + display: 'block', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.625rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.9375rem', + outline: 'none', + boxSizing: 'border-box', + background: 'var(--color-bg-card)', + color: 'var(--color-text)', +}; + +export function CustomFieldsForm({ fields, onChange }: CustomFieldsFormProps) { + const [values, setValues] = useState>({}); + + // Initialisierung aus fields + useEffect(() => { + const init: Record = {}; + for (const f of fields) { + init[f.fieldDefId] = f.value; + } + setValues(init); + }, [fields]); + + const updateValue = useCallback( + (fieldDefId: string, value: string | number | boolean | string[] | null) => { + setValues((prev) => { + const next = { ...prev, [fieldDefId]: value }; + onChange(next); + return next; + }); + }, + [onChange], + ); + + if (fields.length === 0) return null; + + return ( +
+
+ Zusatzfelder +
+ {fields.map((field) => ( +
+ + updateValue(field.fieldDefId, v)} + /> +
+ ))} +
+ ); +} + +// --- Per-type field renderer --- + +interface FieldInputProps { + field: CustomFieldValue; + value: string | number | boolean | string[] | null; + onChange: (value: string | number | boolean | string[] | null) => void; +} + +function FieldInput({ field, value, onChange }: FieldInputProps) { + switch (field.fieldType) { + case 'TEXT': + return ( + onChange(e.target.value || null)} + placeholder={field.label} + /> + ); + + case 'TEXTAREA': + return ( +