From 0ed1e77844fd2c1108e635f7a1e955357a7f4c89 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Wed, 11 Mar 2026 09:21:57 +0100 Subject: [PATCH] feat(crm): add company detail overhaul with industries, account types, relationship types - Backend: new CRUD services/controllers for Industries, AccountTypes, RelationshipTypes, CompanyRelationships with Prisma schema migration - Frontend: new hooks, API functions, and types for all config entities - CompanyDetailPage redesign with ActivityFeed, RelationshipsCard - CompanyFormModal extended with industry, account type, owner fields - Activities service now supports companyId filter + includeContacts Co-Authored-By: Claude Opus 4.6 --- packages/crm-service/Summarize.md | 81 ++-- packages/crm-service/prisma/crm.schema.prisma | 146 ++++++- .../migration.sql | 217 ++++++++++ .../crm-service/prisma/seed-config-data.sql | 46 +++ .../account-types/account-types.controller.ts | 100 +++++ .../src/account-types/account-types.module.ts | 10 + .../account-types/account-types.service.ts | 92 +++++ .../dto/create-account-type.dto.ts | 15 + .../dto/update-account-type.dto.ts | 16 + .../src/activities/activities.service.ts | 90 +++- .../src/activities/dto/create-activity.dto.ts | 13 +- .../activities/dto/query-activities.dto.ts | 17 +- packages/crm-service/src/app.module.ts | 8 + .../src/companies/companies.service.ts | 40 +- .../src/companies/dto/create-company.dto.ts | 24 +- .../src/companies/dto/update-company.dto.ts | 22 + .../company-relationships.controller.ts | 82 ++++ .../company-relationships.module.ts | 10 + .../company-relationships.service.ts | 154 +++++++ .../dto/create-relationship.dto.ts | 17 + .../src/industries/dto/create-industry.dto.ts | 35 ++ .../src/industries/dto/update-industry.dto.ts | 32 ++ .../src/industries/industries.controller.ts | 98 +++++ .../src/industries/industries.module.ts | 10 + .../src/industries/industries.service.ts | 96 +++++ .../dto/create-relationship-type.dto.ts | 15 + .../dto/update-relationship-type.dto.ts | 16 + .../relationship-types.controller.ts | 102 +++++ .../relationship-types.module.ts | 10 + .../relationship-types.service.ts | 92 +++++ .../src/crm/activities/ActivityFormModal.tsx | 7 +- packages/frontend/src/crm/api.ts | 132 ++++++ .../src/crm/companies/ActivityFeed.tsx | 230 +++++++++++ .../companies/CompanyDetailPage.module.css | 389 +++++++++++++++++- .../src/crm/companies/CompanyDetailPage.tsx | 242 ++++------- .../src/crm/companies/CompanyFormModal.tsx | 101 ++++- .../companies/CompanyRelationshipsCard.tsx | 311 ++++++++++++++ .../src/crm/companies/ContractsCard.tsx | 17 + packages/frontend/src/crm/deals/DealsPage.tsx | 4 +- packages/frontend/src/crm/hooks.ts | 253 ++++++++++++ packages/frontend/src/crm/types.ts | 138 ++++++- 41 files changed, 3295 insertions(+), 235 deletions(-) create mode 100644 packages/crm-service/prisma/migrations/20260311_add_company_detail_overhaul/migration.sql create mode 100644 packages/crm-service/prisma/seed-config-data.sql create mode 100644 packages/crm-service/src/account-types/account-types.controller.ts create mode 100644 packages/crm-service/src/account-types/account-types.module.ts create mode 100644 packages/crm-service/src/account-types/account-types.service.ts create mode 100644 packages/crm-service/src/account-types/dto/create-account-type.dto.ts create mode 100644 packages/crm-service/src/account-types/dto/update-account-type.dto.ts create mode 100644 packages/crm-service/src/company-relationships/company-relationships.controller.ts create mode 100644 packages/crm-service/src/company-relationships/company-relationships.module.ts create mode 100644 packages/crm-service/src/company-relationships/company-relationships.service.ts create mode 100644 packages/crm-service/src/company-relationships/dto/create-relationship.dto.ts create mode 100644 packages/crm-service/src/industries/dto/create-industry.dto.ts create mode 100644 packages/crm-service/src/industries/dto/update-industry.dto.ts create mode 100644 packages/crm-service/src/industries/industries.controller.ts create mode 100644 packages/crm-service/src/industries/industries.module.ts create mode 100644 packages/crm-service/src/industries/industries.service.ts create mode 100644 packages/crm-service/src/relationship-types/dto/create-relationship-type.dto.ts create mode 100644 packages/crm-service/src/relationship-types/dto/update-relationship-type.dto.ts create mode 100644 packages/crm-service/src/relationship-types/relationship-types.controller.ts create mode 100644 packages/crm-service/src/relationship-types/relationship-types.module.ts create mode 100644 packages/crm-service/src/relationship-types/relationship-types.service.ts create mode 100644 packages/frontend/src/crm/companies/ActivityFeed.tsx create mode 100644 packages/frontend/src/crm/companies/CompanyRelationshipsCard.tsx create mode 100644 packages/frontend/src/crm/companies/ContractsCard.tsx diff --git a/packages/crm-service/Summarize.md b/packages/crm-service/Summarize.md index 59877d6..c8bf15f 100644 --- a/packages/crm-service/Summarize.md +++ b/packages/crm-service/Summarize.md @@ -1,6 +1,6 @@ # CRM-Service - Zusammenfassung -## Stand: 2026-03-10 +## Stand: 2026-03-11 ### Was wurde erstellt @@ -26,13 +26,17 @@ packages/crm-service/ redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks) auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter - companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update) + companies/ — CRUD: Unternehmen (mit Lexware ERP-Push, industryId, accountTypeId, ownerId) contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update) - activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK) + activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK; contactId+companyId optional) pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update) deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe) + account-types/ — CRUD: Kontotypen (admin-konfigurierbar) + relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar) + company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional) health/ — Health-Check (DB, Redis, Lexware) - lexware/ — Lexware Office Integration (NEU) + lexware/ — Lexware Office Integration lexware.module.ts — Feature Module (HttpModule + ScheduleModule) lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s) lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync @@ -49,29 +53,40 @@ packages/crm-service/ ### Datenbank-Modelle (app_crm Schema) -- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt) +- **Company** — Unternehmen mit industryId, accountTypeId, ownerId, Lexware-Verknuepfung - **Contact** — Kontakte mit optionaler Lexware-Verknuepfung -- **Activity** — Aktivitaeten verknuepft mit Kontakten +- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) - **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant - **PipelineStage** — Stufen innerhalb einer Pipeline - **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen -- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften) -- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) +- **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant) +- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant) +- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant) +- **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen +- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter) +- **LexwareVoucher** — Gecachte Belege aus Lexware Office +- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) ### Entity-Beziehungen ``` -Company (1) --< (n) Contact — companyId (optional, SetNull) -Company (1) --< (n) Deal — companyId (optional, SetNull) -Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull) -Contact (1) --< (n) Activity — contactId (Cascade) -Contact (1) --< (n) Deal — contactId (optional, SetNull) -Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull) -Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade) -Pipeline (1) --< (n) Deal — pipelineId (Cascade) -PipelineStage (1) --< (n) Deal — stageId -Deal (1) --< (n) DealVoucher — dealId (Cascade) -LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) +Company (1) --< (n) Contact — companyId (optional, SetNull) +Company (1) --< (n) Deal — companyId (optional, SetNull) +Company (1) --< (n) Activity — companyId (optional, SetNull) +Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull) +Company (n) >--< (n) Company — via CompanyRelationship (bidirektional) +Company (1) --< (n) Contract — companyId (Cascade) +Company (n) --> (1) Industry — industryId (optional, SetNull) +Company (n) --> (1) AccountType — accountTypeId (optional, SetNull) +Contact (1) --< (n) Activity — contactId (optional, SetNull) +Contact (1) --< (n) Deal — contactId (optional, SetNull) +Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull) +Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade) +Pipeline (1) --< (n) Deal — pipelineId (Cascade) +PipelineStage (1) --< (n) Deal — stageId +Deal (1) --< (n) DealVoucher — dealId (Cascade) +LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) +RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId ``` ### API-Endpunkte @@ -82,8 +97,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) | GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete | | GET/POST | /api/v1/crm/contacts | Liste / Erstellen | | GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete | -| GET/POST | /api/v1/crm/activities | Liste / Erstellen | +| GET/POST | /api/v1/crm/activities | Liste / Erstellen (companyId+includeContacts fuer aggregierten Feed) | | GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete | +| GET/POST | /api/v1/crm/industries | Branchen verwalten | +| PATCH/DELETE | /api/v1/crm/industries/:id | Branche bearbeiten / loeschen | +| GET/POST | /api/v1/crm/account-types | Kontotypen verwalten | +| PATCH/DELETE | /api/v1/crm/account-types/:id | Kontotyp bearbeiten / loeschen | +| GET/POST | /api/v1/crm/relationship-types | Beziehungstypen verwalten | +| PATCH/DELETE | /api/v1/crm/relationship-types/:id | Beziehungstyp bearbeiten / loeschen | +| GET/POST | /api/v1/crm/companies/:id/relationships | Beziehungen verwalten | +| DELETE | /api/v1/crm/companies/:id/relationships/:relId | Beziehung loeschen | +| GET | /api/v1/crm/users | Tenant-User fuer Owner-Dropdown | | GET/POST | /api/v1/crm/pipelines | Liste / Erstellen | | GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete | | POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen | @@ -141,14 +165,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) - Prisma Migrationen: - `20260310163211_init` — Initiales Schema - `20260310183117_add_companies` — Company-Entity - - `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND) + - `20260310_add_lexware_integration` — Lexware Office Integration + - `20260311_add_company_detail_overhaul` — Company Detail Overhaul (Industry, AccountType, RelationshipType, CompanyRelationship, Contract, Activity companyId) ### Naechste Schritte -1. Migration `20260310_add_lexware_integration` auf Server anwenden -2. `LEXWARE_API_KEY` in `.env` auf Server setzen +1. Migration `20260311_add_company_detail_overhaul` auf Server anwenden +2. Seed-Data (Industries, AccountTypes, RelationshipTypes) ausfuehren 3. Container neu bauen und deployen -4. Lexware-Endpunkte auf Server testen -5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten -6. Activity-Liste komplett laden (UI-Button "Alle anzeigen") -7. Kanban-Board fuer Vorgaenge +4. Frontend testen: CompanyDetailPage 3-Spalten Layout +5. CRM-Einstellungen: Branchen/Kontotypen/Beziehungstypen verwalten +6. CompanyFormModal: Dropdowns testen +7. Activity Feed: Aggregierten Feed testen +8. Kanban-Board fuer Vorgaenge +9. Vertraege-UI implementieren (DB-Modell bereits vorhanden) diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index 8bb0e82..c010913 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -17,6 +17,64 @@ datasource db { schemas = ["app_crm"] } +// -------------------------------------------------------- +// Industry - Branche (admin-konfigurierbar pro Tenant) +// -------------------------------------------------------- +model Industry { + 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") + + companies Company[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("industries") + @@schema("app_crm") +} + +// -------------------------------------------------------- +// AccountType - Kontotyp (admin-konfigurierbar pro Tenant) +// -------------------------------------------------------- +model AccountType { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + companies Company[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("account_types") + @@schema("app_crm") +} + +// -------------------------------------------------------- +// RelationshipType - Beziehungstyp (admin-konfigurierbar) +// -------------------------------------------------------- +model RelationshipType { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + relationships CompanyRelationship[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("relationship_types") + @@schema("app_crm") +} + // -------------------------------------------------------- // Company - Unternehmen (uebergeordnete Entity) // -------------------------------------------------------- @@ -27,6 +85,12 @@ model Company { name String @db.VarChar(200) industry String? @db.VarChar(100) + // Admin-konfigurierbare Felder + industryId String? @map("industry_id") @db.Uuid + accountTypeId String? @map("account_type_id") @db.Uuid + ownerId String? @map("owner_id") @db.Uuid + ownerName String? @map("owner_name") @db.VarChar(200) + // Kontaktdaten email String? @db.VarChar(255) phone String? @db.VarChar(50) @@ -58,14 +122,22 @@ model Company { updatedAt DateTime @updatedAt @map("updated_at") // Relationen - contacts Contact[] - deals Deal[] - lexwareVouchers LexwareVoucher[] + industryRef Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull) + accountType AccountType? @relation(fields: [accountTypeId], references: [id], onDelete: SetNull) + contacts Contact[] + deals Deal[] + activities Activity[] + lexwareVouchers LexwareVoucher[] + relationships CompanyRelationship[] @relation("companyRelationships") + relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships") + contracts Contract[] @@unique([tenantId, lexwareContactId]) @@index([tenantId]) @@index([tenantId, name]) @@index([tenantId, industry]) + @@index([tenantId, industryId]) + @@index([tenantId, accountTypeId]) @@index([tenantId, isActive]) @@map("companies") @@schema("app_crm") @@ -149,7 +221,8 @@ enum ContactType { model Activity { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid - contactId String @map("contact_id") @db.Uuid + contactId String? @map("contact_id") @db.Uuid + companyId String? @map("company_id") @db.Uuid type ActivityType subject String @db.VarChar(500) description String? @db.Text @@ -166,10 +239,12 @@ model Activity { updatedAt DateTime @updatedAt @map("updated_at") // Relationen - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) + company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade) @@index([tenantId]) @@index([tenantId, contactId]) + @@index([tenantId, companyId]) @@index([tenantId, type]) @@index([tenantId, scheduledAt]) @@map("activities") @@ -186,6 +261,67 @@ enum ActivityType { @@schema("app_crm") } +// -------------------------------------------------------- +// CompanyRelationship - Beziehungen zwischen Unternehmen (N:M) +// -------------------------------------------------------- +model CompanyRelationship { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + companyId String @map("company_id") @db.Uuid + relatedCompanyId String @map("related_company_id") @db.Uuid + relationshipTypeId String @map("relationship_type_id") @db.Uuid + notes String? @db.Text + createdBy String @map("created_by") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + // Relationen + company Company @relation("companyRelationships", fields: [companyId], references: [id], onDelete: Cascade) + relatedCompany Company @relation("relatedCompanyRelationships", fields: [relatedCompanyId], references: [id], onDelete: Cascade) + relationshipType RelationshipType @relation(fields: [relationshipTypeId], references: [id], onDelete: Restrict) + + @@unique([companyId, relatedCompanyId, relationshipTypeId]) + @@index([tenantId]) + @@index([tenantId, companyId]) + @@index([tenantId, relatedCompanyId]) + @@map("company_relationships") + @@schema("app_crm") +} + +// -------------------------------------------------------- +// Contract - Vertraege (Platzhalter fuer zukuenftiges Modul) +// -------------------------------------------------------- +model Contract { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + companyId String @map("company_id") @db.Uuid + title String @db.VarChar(255) + status ContractStatus @default(DRAFT) + startDate DateTime? @map("start_date") + endDate DateTime? @map("end_date") + value Decimal? @db.Decimal(15, 2) + currency String @default("EUR") @db.VarChar(3) + notes String? @db.Text + createdBy String @map("created_by") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + + @@index([tenantId, companyId]) + @@map("contracts") + @@schema("app_crm") +} + +enum ContractStatus { + DRAFT + ACTIVE + EXPIRED + CANCELLED + + @@schema("app_crm") +} + // -------------------------------------------------------- // Pipeline - Sales-Pipelines (konfigurierbar pro Tenant) // -------------------------------------------------------- diff --git a/packages/crm-service/prisma/migrations/20260311_add_company_detail_overhaul/migration.sql b/packages/crm-service/prisma/migrations/20260311_add_company_detail_overhaul/migration.sql new file mode 100644 index 0000000..4e8c036 --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260311_add_company_detail_overhaul/migration.sql @@ -0,0 +1,217 @@ +-- ============================================================ +-- Company Detail Overhaul - Schema Migration +-- ============================================================ +-- Neue Tabellen: industries, account_types, relationship_types, +-- company_relationships, contracts +-- Neue Enums: ContractStatus +-- Erweiterte Tabellen: companies (industryId, accountTypeId, ownerId), +-- activities (contactId optional, companyId neu) +-- ============================================================ + +-- ContractStatus Enum +CREATE TYPE "app_crm"."ContractStatus" AS ENUM ('DRAFT', 'ACTIVE', 'EXPIRED', 'CANCELLED'); + +-- -------------------------------------------------------- +-- Industries (Branchen, admin-konfigurierbar) +-- -------------------------------------------------------- +CREATE TABLE "app_crm"."industries" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "color" VARCHAR(7) NOT NULL DEFAULT '#6B7280', + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "industries_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "industries_tenant_id_name_key" + ON "app_crm"."industries"("tenant_id", "name"); + +CREATE INDEX "industries_tenant_id_idx" + ON "app_crm"."industries"("tenant_id"); + +-- -------------------------------------------------------- +-- AccountTypes (Kontotypen, admin-konfigurierbar) +-- -------------------------------------------------------- +CREATE TABLE "app_crm"."account_types" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "account_types_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "account_types_tenant_id_name_key" + ON "app_crm"."account_types"("tenant_id", "name"); + +CREATE INDEX "account_types_tenant_id_idx" + ON "app_crm"."account_types"("tenant_id"); + +-- -------------------------------------------------------- +-- RelationshipTypes (Beziehungstypen, admin-konfigurierbar) +-- -------------------------------------------------------- +CREATE TABLE "app_crm"."relationship_types" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "relationship_types_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "relationship_types_tenant_id_name_key" + ON "app_crm"."relationship_types"("tenant_id", "name"); + +CREATE INDEX "relationship_types_tenant_id_idx" + ON "app_crm"."relationship_types"("tenant_id"); + +-- -------------------------------------------------------- +-- Company: Neue Felder hinzufuegen +-- -------------------------------------------------------- +ALTER TABLE "app_crm"."companies" + ADD COLUMN "industry_id" UUID, + ADD COLUMN "account_type_id" UUID, + ADD COLUMN "owner_id" UUID, + ADD COLUMN "owner_name" VARCHAR(200); + +-- Indizes fuer neue FK-Felder +CREATE INDEX "companies_tenant_id_industry_id_idx" + ON "app_crm"."companies"("tenant_id", "industry_id"); + +CREATE INDEX "companies_tenant_id_account_type_id_idx" + ON "app_crm"."companies"("tenant_id", "account_type_id"); + +-- Foreign Keys +ALTER TABLE "app_crm"."companies" + ADD CONSTRAINT "companies_industry_id_fkey" + FOREIGN KEY ("industry_id") REFERENCES "app_crm"."industries"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "app_crm"."companies" + ADD CONSTRAINT "companies_account_type_id_fkey" + FOREIGN KEY ("account_type_id") REFERENCES "app_crm"."account_types"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- -------------------------------------------------------- +-- Activity: contactId optional machen, companyId hinzufuegen +-- -------------------------------------------------------- +ALTER TABLE "app_crm"."activities" + ALTER COLUMN "contact_id" DROP NOT NULL; + +ALTER TABLE "app_crm"."activities" + ADD COLUMN "company_id" UUID; + +-- Index fuer companyId +CREATE INDEX "activities_tenant_id_company_id_idx" + ON "app_crm"."activities"("tenant_id", "company_id"); + +-- Foreign Key fuer companyId +ALTER TABLE "app_crm"."activities" + ADD CONSTRAINT "activities_company_id_fkey" + FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- -------------------------------------------------------- +-- CompanyRelationship (N:M Beziehungen zwischen Unternehmen) +-- -------------------------------------------------------- +CREATE TABLE "app_crm"."company_relationships" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "company_id" UUID NOT NULL, + "related_company_id" UUID NOT NULL, + "relationship_type_id" UUID NOT NULL, + "notes" TEXT, + "created_by" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "company_relationships_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "company_relationships_company_id_related_company_id_relationship_type_id_key" + ON "app_crm"."company_relationships"("company_id", "related_company_id", "relationship_type_id"); + +CREATE INDEX "company_relationships_tenant_id_idx" + ON "app_crm"."company_relationships"("tenant_id"); + +CREATE INDEX "company_relationships_tenant_id_company_id_idx" + ON "app_crm"."company_relationships"("tenant_id", "company_id"); + +CREATE INDEX "company_relationships_tenant_id_related_company_id_idx" + ON "app_crm"."company_relationships"("tenant_id", "related_company_id"); + +ALTER TABLE "app_crm"."company_relationships" + ADD CONSTRAINT "company_relationships_company_id_fkey" + FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "app_crm"."company_relationships" + ADD CONSTRAINT "company_relationships_related_company_id_fkey" + FOREIGN KEY ("related_company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "app_crm"."company_relationships" + ADD CONSTRAINT "company_relationships_relationship_type_id_fkey" + FOREIGN KEY ("relationship_type_id") REFERENCES "app_crm"."relationship_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- -------------------------------------------------------- +-- Contract (Vertraege - Platzhalter) +-- -------------------------------------------------------- +CREATE TABLE "app_crm"."contracts" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenant_id" UUID NOT NULL, + "company_id" UUID NOT NULL, + "title" VARCHAR(255) NOT NULL, + "status" "app_crm"."ContractStatus" NOT NULL DEFAULT 'DRAFT', + "start_date" TIMESTAMP(3), + "end_date" TIMESTAMP(3), + "value" DECIMAL(15,2), + "currency" VARCHAR(3) NOT NULL DEFAULT 'EUR', + "notes" TEXT, + "created_by" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contracts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "contracts_tenant_id_company_id_idx" + ON "app_crm"."contracts"("tenant_id", "company_id"); + +ALTER TABLE "app_crm"."contracts" + ADD CONSTRAINT "contracts_company_id_fkey" + FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- -------------------------------------------------------- +-- Daten-Migration: Bestehende industry-Freitext-Werte migrieren +-- -------------------------------------------------------- +-- Hinweis: Diese Migration erstellt Industry-Records aus bestehenden +-- Freitext-Werten. Das alte industry-Feld bleibt bestehen als Fallback. +-- In einer spaeteren Migration kann es entfernt werden. + +-- Schritt 1: Unique industry-Werte als Industry-Records erstellen +INSERT INTO "app_crm"."industries" ("id", "tenant_id", "name", "color", "sort_order", "created_at", "updated_at") +SELECT + gen_random_uuid(), + c."tenant_id", + c."industry", + '#6B7280', + 0, + NOW(), + NOW() +FROM ( + SELECT DISTINCT "tenant_id", "industry" + FROM "app_crm"."companies" + WHERE "industry" IS NOT NULL AND "industry" != '' +) c +ON CONFLICT ("tenant_id", "name") DO NOTHING; + +-- Schritt 2: industry_id auf Companies setzen +UPDATE "app_crm"."companies" co +SET "industry_id" = i."id" +FROM "app_crm"."industries" i +WHERE co."tenant_id" = i."tenant_id" + AND co."industry" = i."name" + AND co."industry" IS NOT NULL + AND co."industry" != ''; diff --git a/packages/crm-service/prisma/seed-config-data.sql b/packages/crm-service/prisma/seed-config-data.sql new file mode 100644 index 0000000..7df6357 --- /dev/null +++ b/packages/crm-service/prisma/seed-config-data.sql @@ -0,0 +1,46 @@ +-- ============================================================ +-- INSIGHT CRM - Default Konfigurationsdaten (Seed) +-- ============================================================ +-- Ausfuehrung: Manuell nach Migration auf dem Server +-- Hinweis: {TENANT_ID} muss durch die echte Tenant-UUID ersetzt werden +-- ============================================================ + +-- Ersetze diese Variable mit der echten Tenant-ID: +-- SET session my.tenant_id = 'DEINE-TENANT-UUID-HIER'; + +-- -------------------------------------------------------- +-- Default Industries (Branchen) +-- -------------------------------------------------------- +INSERT INTO "app_crm"."industries" ("id", "tenant_id", "name", "color", "sort_order", "created_at", "updated_at") +VALUES + (gen_random_uuid(), '{TENANT_ID}', 'IT & Software', '#3B82F6', 1, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Produktion', '#F59E0B', 2, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Handel', '#10B981', 3, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Dienstleistung', '#8B5CF6', 4, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Gesundheit', '#EF4444', 5, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Finanzen', '#6366F1', 6, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Bildung', '#EC4899', 7, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Oeffentlicher Sektor', '#6B7280', 8, NOW(), NOW()) +ON CONFLICT ("tenant_id", "name") DO NOTHING; + +-- -------------------------------------------------------- +-- Default AccountTypes (Kontotypen) +-- -------------------------------------------------------- +INSERT INTO "app_crm"."account_types" ("id", "tenant_id", "name", "sort_order", "created_at", "updated_at") +VALUES + (gen_random_uuid(), '{TENANT_ID}', 'Interessent', 1, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Endkunde', 2, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Personaldienstleister', 3, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Partner', 4, NOW(), NOW()) +ON CONFLICT ("tenant_id", "name") DO NOTHING; + +-- -------------------------------------------------------- +-- Default RelationshipTypes (Beziehungstypen) +-- -------------------------------------------------------- +INSERT INTO "app_crm"."relationship_types" ("id", "tenant_id", "name", "sort_order", "created_at", "updated_at") +VALUES + (gen_random_uuid(), '{TENANT_ID}', 'Endkunde', 1, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Abrechnungspartner', 2, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Muttergesellschaft', 3, NOW(), NOW()), + (gen_random_uuid(), '{TENANT_ID}', 'Tochtergesellschaft', 4, NOW(), NOW()) +ON CONFLICT ("tenant_id", "name") DO NOTHING; diff --git a/packages/crm-service/src/account-types/account-types.controller.ts b/packages/crm-service/src/account-types/account-types.controller.ts new file mode 100644 index 0000000..0fb749c --- /dev/null +++ b/packages/crm-service/src/account-types/account-types.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { AccountTypesService } from './account-types.service'; +import { CreateAccountTypeDto } from './dto/create-account-type.dto'; +import { UpdateAccountTypeDto } from './dto/update-account-type.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('Account Types') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('account-types') +export class AccountTypesController { + constructor(private readonly accountTypesService: AccountTypesService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Kontotyp erstellen' }) + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateAccountTypeDto, + ) { + const accountType = await this.accountTypesService.create( + user.tenantId!, + dto, + ); + return singleResponse(accountType); + } + + @Get() + @ApiOperation({ summary: 'Alle Kontotypen auflisten' }) + async findAll(@CurrentUser() user: JwtPayload) { + const accountTypes = await this.accountTypesService.findAll( + user.tenantId!, + ); + return { data: accountTypes }; + } + + @Get(':id') + @ApiOperation({ summary: 'Kontotyp abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const accountType = await this.accountTypesService.findOne( + user.tenantId!, + id, + ); + return singleResponse(accountType); + } + + @Patch(':id') + @ApiOperation({ summary: 'Kontotyp aktualisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAccountTypeDto, + ) { + const accountType = await this.accountTypesService.update( + user.tenantId!, + id, + dto, + ); + return singleResponse(accountType); + } + + @Delete(':id') + @ApiOperation({ summary: 'Kontotyp loeschen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const accountType = await this.accountTypesService.remove( + user.tenantId!, + id, + ); + return singleResponse(accountType); + } +} diff --git a/packages/crm-service/src/account-types/account-types.module.ts b/packages/crm-service/src/account-types/account-types.module.ts new file mode 100644 index 0000000..097d3f7 --- /dev/null +++ b/packages/crm-service/src/account-types/account-types.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AccountTypesController } from './account-types.controller'; +import { AccountTypesService } from './account-types.service'; + +@Module({ + controllers: [AccountTypesController], + providers: [AccountTypesService], + exports: [AccountTypesService], +}) +export class AccountTypesModule {} diff --git a/packages/crm-service/src/account-types/account-types.service.ts b/packages/crm-service/src/account-types/account-types.service.ts new file mode 100644 index 0000000..1e30022 --- /dev/null +++ b/packages/crm-service/src/account-types/account-types.service.ts @@ -0,0 +1,92 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateAccountTypeDto } from './dto/create-account-type.dto'; +import { UpdateAccountTypeDto } from './dto/update-account-type.dto'; + +@Injectable() +export class AccountTypesService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create(tenantId: string, dto: CreateAccountTypeDto) { + const existing = await this.prisma.accountType.findUnique({ + where: { tenantId_name: { tenantId, name: dto.name } }, + }); + if (existing) { + throw new ConflictException( + `Kontotyp "${dto.name}" existiert bereits`, + ); + } + + return this.prisma.accountType.create({ + data: { + tenantId, + name: dto.name, + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async findAll(tenantId: string) { + return this.prisma.accountType.findMany({ + where: { tenantId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { companies: true } }, + }, + }); + } + + async findOne(tenantId: string, id: string) { + const accountType = await this.prisma.accountType.findFirst({ + where: { id, tenantId }, + include: { + _count: { select: { companies: true } }, + }, + }); + + if (!accountType) { + throw new NotFoundException('Kontotyp nicht gefunden'); + } + + return accountType; + } + + async update(tenantId: string, id: string, dto: UpdateAccountTypeDto) { + await this.findOne(tenantId, id); + + if (dto.name) { + const existing = await this.prisma.accountType.findFirst({ + where: { tenantId, name: dto.name, NOT: { id } }, + }); + if (existing) { + throw new ConflictException( + `Kontotyp "${dto.name}" existiert bereits`, + ); + } + } + + return this.prisma.accountType.update({ + where: { id }, + data: dto, + include: { + _count: { select: { companies: true } }, + }, + }); + } + + async remove(tenantId: string, id: string) { + const accountType = await this.findOne(tenantId, id); + + if (accountType._count.companies > 0) { + throw new ConflictException( + `Kontotyp kann nicht geloescht werden — ${accountType._count.companies} Unternehmen zugeordnet`, + ); + } + + return this.prisma.accountType.delete({ where: { id } }); + } +} diff --git a/packages/crm-service/src/account-types/dto/create-account-type.dto.ts b/packages/crm-service/src/account-types/dto/create-account-type.dto.ts new file mode 100644 index 0000000..f32f29b --- /dev/null +++ b/packages/crm-service/src/account-types/dto/create-account-type.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsInt, MaxLength, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateAccountTypeDto { + @ApiProperty({ maxLength: 100, description: 'Name des Kontotyps' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ default: 0, description: 'Sortierreihenfolge' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/account-types/dto/update-account-type.dto.ts b/packages/crm-service/src/account-types/dto/update-account-type.dto.ts new file mode 100644 index 0000000..cd0cebe --- /dev/null +++ b/packages/crm-service/src/account-types/dto/update-account-type.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional, IsInt, MaxLength, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateAccountTypeDto { + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/activities/activities.service.ts b/packages/crm-service/src/activities/activities.service.ts index 744c798..43426e5 100644 --- a/packages/crm-service/src/activities/activities.service.ts +++ b/packages/crm-service/src/activities/activities.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateActivityDto } from './dto/create-activity.dto'; import { UpdateActivityDto } from './dto/update-activity.dto'; @@ -10,18 +14,38 @@ export class ActivitiesService { constructor(private readonly prisma: CrmPrismaService) {} async create(tenantId: string, userId: string, dto: CreateActivityDto) { - // Pruefen ob der Kontakt existiert und zum Tenant gehoert - const contact = await this.prisma.contact.findFirst({ - where: { id: dto.contactId, tenantId }, - }); - if (!contact) { - throw new NotFoundException('Kontakt nicht gefunden'); + // Mindestens contactId oder companyId muss gesetzt sein + if (!dto.contactId && !dto.companyId) { + throw new BadRequestException( + 'Mindestens contactId oder companyId muss angegeben werden', + ); + } + + // Kontakt validieren (falls gesetzt) + if (dto.contactId) { + const contact = await this.prisma.contact.findFirst({ + where: { id: dto.contactId, tenantId }, + }); + if (!contact) { + throw new NotFoundException('Kontakt nicht gefunden'); + } + } + + // Unternehmen validieren (falls gesetzt) + if (dto.companyId) { + const company = await this.prisma.company.findFirst({ + where: { id: dto.companyId, tenantId }, + }); + if (!company) { + throw new NotFoundException('Unternehmen nicht gefunden'); + } } return this.prisma.activity.create({ data: { tenantId, contactId: dto.contactId, + companyId: dto.companyId, type: dto.type, subject: dto.subject, description: dto.description, @@ -29,7 +53,14 @@ export class ActivitiesService { completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined, createdBy: userId, }, - include: { contact: true }, + include: { + contact: { + select: { id: true, firstName: true, lastName: true, companyName: true }, + }, + company: { + select: { id: true, name: true }, + }, + }, }); } @@ -39,9 +70,27 @@ export class ActivitiesService { const where: Prisma.ActivityWhereInput = { tenantId }; - if (query.contactId) { + // Aggregierter Company-Feed: direkte + verknuepfte Kontakt-Aktivitaeten + if (query.companyId && query.includeContacts) { + const contactIds = await this.prisma.contact + .findMany({ + where: { tenantId, companyId: query.companyId }, + select: { id: true }, + }) + .then((contacts) => contacts.map((c) => c.id)); + + where.OR = [ + { companyId: query.companyId }, + ...(contactIds.length > 0 + ? [{ contactId: { in: contactIds } }] + : []), + ]; + } else if (query.companyId) { + where.companyId = query.companyId; + } else if (query.contactId) { where.contactId = query.contactId; } + if (query.type) { where.type = query.type; } @@ -57,7 +106,14 @@ export class ActivitiesService { skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortField]: query.order ?? 'desc' }, - include: { contact: { select: { id: true, firstName: true, lastName: true, companyName: true } } }, + include: { + contact: { + select: { id: true, firstName: true, lastName: true, companyName: true }, + }, + company: { + select: { id: true, name: true }, + }, + }, }), this.prisma.activity.count({ where }), ]); @@ -68,7 +124,10 @@ export class ActivitiesService { async findOne(tenantId: string, id: string) { const activity = await this.prisma.activity.findFirst({ where: { id, tenantId }, - include: { contact: true }, + include: { + contact: true, + company: { select: { id: true, name: true } }, + }, }); if (!activity) { @@ -94,7 +153,14 @@ export class ActivitiesService { completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined, updatedBy: userId, }, - include: { contact: true }, + include: { + contact: { + select: { id: true, firstName: true, lastName: true, companyName: true }, + }, + company: { + select: { id: true, name: true }, + }, + }, }); } diff --git a/packages/crm-service/src/activities/dto/create-activity.dto.ts b/packages/crm-service/src/activities/dto/create-activity.dto.ts index 9136378..92aa6b4 100644 --- a/packages/crm-service/src/activities/dto/create-activity.dto.ts +++ b/packages/crm-service/src/activities/dto/create-activity.dto.ts @@ -5,6 +5,7 @@ import { IsUUID, IsDateString, MaxLength, + ValidateIf, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -17,9 +18,17 @@ export enum ActivityType { } export class CreateActivityDto { - @ApiProperty({ format: 'uuid' }) + @ApiPropertyOptional({ format: 'uuid', description: 'Kontakt-ID (optional wenn companyId gesetzt)' }) + @IsOptional() + @ValidateIf((o) => !o.companyId) @IsUUID() - contactId!: string; + contactId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Unternehmens-ID (optional wenn contactId gesetzt)' }) + @IsOptional() + @ValidateIf((o) => !o.contactId) + @IsUUID() + companyId?: string; @ApiProperty({ enum: ActivityType }) @IsEnum(ActivityType) diff --git a/packages/crm-service/src/activities/dto/query-activities.dto.ts b/packages/crm-service/src/activities/dto/query-activities.dto.ts index 109a62c..9424a01 100644 --- a/packages/crm-service/src/activities/dto/query-activities.dto.ts +++ b/packages/crm-service/src/activities/dto/query-activities.dto.ts @@ -1,5 +1,6 @@ -import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsEnum, IsUUID, IsBoolean } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { PaginationDto } from '../../common/dto/pagination.dto'; import { ActivityType } from './create-activity.dto'; @@ -9,6 +10,20 @@ export class QueryActivitiesDto extends PaginationDto { @IsUUID() contactId?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Unternehmen' }) + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional({ + description: 'Kontakt-Aktivitaeten inkludieren (nur bei companyId)', + default: false, + }) + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + includeContacts?: boolean; + @ApiPropertyOptional({ enum: ActivityType }) @IsOptional() @IsEnum(ActivityType) diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index 3709055..eb6ab26 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -15,6 +15,10 @@ import { PipelinesModule } from './pipelines/pipelines.module'; import { DealsModule } from './deals/deals.module'; import { CompaniesModule } from './companies/companies.module'; import { LexwareModule } from './lexware/lexware.module'; +import { IndustriesModule } from './industries/industries.module'; +import { AccountTypesModule } from './account-types/account-types.module'; +import { RelationshipTypesModule } from './relationship-types/relationship-types.module'; +import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module'; @Module({ imports: [ @@ -33,6 +37,10 @@ import { LexwareModule } from './lexware/lexware.module'; DealsModule, CompaniesModule, LexwareModule, + IndustriesModule, + AccountTypesModule, + RelationshipTypesModule, + CompanyRelationshipsModule, ], providers: [ { diff --git a/packages/crm-service/src/companies/companies.service.ts b/packages/crm-service/src/companies/companies.service.ts index f8fcb6f..a33610b 100644 --- a/packages/crm-service/src/companies/companies.service.ts +++ b/packages/crm-service/src/companies/companies.service.ts @@ -22,6 +22,10 @@ export class CompaniesService { createdBy: userId, name: dto.name, industry: dto.industry, + industryId: dto.industryId, + accountTypeId: dto.accountTypeId, + ownerId: dto.ownerId, + ownerName: dto.ownerName, email: dto.email, phone: dto.phone, website: dto.website, @@ -35,6 +39,8 @@ export class CompaniesService { isActive: dto.isActive ?? true, }, include: { + industryRef: true, + accountType: true, _count: { select: { contacts: true, deals: true } }, }, }); @@ -77,6 +83,8 @@ export class CompaniesService { take: pageSize, orderBy: { [sortField]: query.order ?? 'desc' }, include: { + industryRef: true, + accountType: true, _count: { select: { contacts: true, deals: true } }, }, }), @@ -90,6 +98,8 @@ export class CompaniesService { const company = await this.prisma.company.findFirst({ where: { id, tenantId }, include: { + industryRef: true, + accountType: true, contacts: { where: { isActive: true }, orderBy: { createdAt: 'desc' }, @@ -112,8 +122,34 @@ export class CompaniesService { stage: { select: { id: true, name: true, color: true } }, }, }, + relationships: { + include: { + relatedCompany: { + select: { id: true, name: true, industry: true, city: true }, + }, + relationshipType: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }, + relatedRelationships: { + include: { + company: { + select: { id: true, name: true, industry: true, city: true }, + }, + relationshipType: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }, + contracts: { + orderBy: { createdAt: 'desc' }, + take: 10, + }, _count: { - select: { contacts: true, deals: true, lexwareVouchers: true }, + select: { contacts: true, deals: true, lexwareVouchers: true, contracts: true }, }, }, }); @@ -140,6 +176,8 @@ export class CompaniesService { updatedBy: userId, }, include: { + industryRef: true, + accountType: true, _count: { select: { contacts: true, deals: true } }, }, }); diff --git a/packages/crm-service/src/companies/dto/create-company.dto.ts b/packages/crm-service/src/companies/dto/create-company.dto.ts index 84e8299..69ba62a 100644 --- a/packages/crm-service/src/companies/dto/create-company.dto.ts +++ b/packages/crm-service/src/companies/dto/create-company.dto.ts @@ -4,6 +4,7 @@ import { IsBoolean, IsEmail, IsUrl, + IsUUID, IsArray, MaxLength, } from 'class-validator'; @@ -15,12 +16,33 @@ export class CreateCompanyDto { @MaxLength(200) name!: string; - @ApiPropertyOptional({ maxLength: 100 }) + @ApiPropertyOptional({ maxLength: 100, description: 'Freitext-Branche (Legacy)' }) @IsOptional() @IsString() @MaxLength(100) industry?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Branchen-ID' }) + @IsOptional() + @IsUUID() + industryId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Kontotyp-ID' }) + @IsOptional() + @IsUUID() + accountTypeId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Zustaendiger User (Owner-ID)' }) + @IsOptional() + @IsUUID() + ownerId?: string; + + @ApiPropertyOptional({ maxLength: 200, description: 'Name des zustaendigen Users' }) + @IsOptional() + @IsString() + @MaxLength(200) + ownerName?: string; + @ApiPropertyOptional({ maxLength: 255 }) @IsOptional() @IsEmail() diff --git a/packages/crm-service/src/companies/dto/update-company.dto.ts b/packages/crm-service/src/companies/dto/update-company.dto.ts index ebc1e09..232b99a 100644 --- a/packages/crm-service/src/companies/dto/update-company.dto.ts +++ b/packages/crm-service/src/companies/dto/update-company.dto.ts @@ -4,6 +4,7 @@ import { IsBoolean, IsEmail, IsUrl, + IsUUID, IsArray, MaxLength, } from 'class-validator'; @@ -22,6 +23,27 @@ export class UpdateCompanyDto { @MaxLength(100) industry?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Branchen-ID' }) + @IsOptional() + @IsUUID() + industryId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Kontotyp-ID' }) + @IsOptional() + @IsUUID() + accountTypeId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Zustaendiger User (Owner-ID)' }) + @IsOptional() + @IsUUID() + ownerId?: string; + + @ApiPropertyOptional({ maxLength: 200, description: 'Name des zustaendigen Users' }) + @IsOptional() + @IsString() + @MaxLength(200) + ownerName?: string; + @ApiPropertyOptional({ maxLength: 255 }) @IsOptional() @IsEmail() diff --git a/packages/crm-service/src/company-relationships/company-relationships.controller.ts b/packages/crm-service/src/company-relationships/company-relationships.controller.ts new file mode 100644 index 0000000..884189d --- /dev/null +++ b/packages/crm-service/src/company-relationships/company-relationships.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { CompanyRelationshipsService } from './company-relationships.service'; +import { CreateRelationshipDto } from './dto/create-relationship.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('Company Relationships') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('companies/:companyId/relationships') +export class CompanyRelationshipsController { + constructor( + private readonly companyRelationshipsService: CompanyRelationshipsService, + ) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Unternehmensbeziehung erstellen' }) + @ApiParam({ name: 'companyId', type: 'string', format: 'uuid' }) + async create( + @CurrentUser() user: JwtPayload, + @Param('companyId', ParseUUIDPipe) companyId: string, + @Body() dto: CreateRelationshipDto, + ) { + const rel = await this.companyRelationshipsService.create( + user.tenantId!, + companyId, + user.sub, + dto, + ); + return singleResponse(rel); + } + + @Get() + @ApiOperation({ summary: 'Beziehungen eines Unternehmens auflisten' }) + @ApiParam({ name: 'companyId', type: 'string', format: 'uuid' }) + async findAll( + @CurrentUser() user: JwtPayload, + @Param('companyId', ParseUUIDPipe) companyId: string, + ) { + const relationships = await this.companyRelationshipsService.findByCompany( + user.tenantId!, + companyId, + ); + return { data: relationships }; + } + + @Delete(':relationshipId') + @ApiOperation({ summary: 'Unternehmensbeziehung loeschen' }) + @ApiParam({ name: 'companyId', type: 'string', format: 'uuid' }) + @ApiParam({ name: 'relationshipId', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('companyId', ParseUUIDPipe) companyId: string, + @Param('relationshipId', ParseUUIDPipe) relationshipId: string, + ) { + const rel = await this.companyRelationshipsService.remove( + user.tenantId!, + companyId, + relationshipId, + ); + return singleResponse(rel); + } +} diff --git a/packages/crm-service/src/company-relationships/company-relationships.module.ts b/packages/crm-service/src/company-relationships/company-relationships.module.ts new file mode 100644 index 0000000..9493e13 --- /dev/null +++ b/packages/crm-service/src/company-relationships/company-relationships.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CompanyRelationshipsController } from './company-relationships.controller'; +import { CompanyRelationshipsService } from './company-relationships.service'; + +@Module({ + controllers: [CompanyRelationshipsController], + providers: [CompanyRelationshipsService], + exports: [CompanyRelationshipsService], +}) +export class CompanyRelationshipsModule {} diff --git a/packages/crm-service/src/company-relationships/company-relationships.service.ts b/packages/crm-service/src/company-relationships/company-relationships.service.ts new file mode 100644 index 0000000..62b60a9 --- /dev/null +++ b/packages/crm-service/src/company-relationships/company-relationships.service.ts @@ -0,0 +1,154 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateRelationshipDto } from './dto/create-relationship.dto'; + +@Injectable() +export class CompanyRelationshipsService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create( + tenantId: string, + companyId: string, + userId: string, + dto: CreateRelationshipDto, + ) { + // Selbst-Referenz verhindern + if (companyId === dto.relatedCompanyId) { + throw new BadRequestException( + 'Ein Unternehmen kann keine Beziehung zu sich selbst haben', + ); + } + + // Company validieren + const company = await this.prisma.company.findFirst({ + where: { id: companyId, tenantId }, + }); + if (!company) { + throw new NotFoundException('Unternehmen nicht gefunden'); + } + + // Related Company validieren + const relatedCompany = await this.prisma.company.findFirst({ + where: { id: dto.relatedCompanyId, tenantId }, + }); + if (!relatedCompany) { + throw new NotFoundException('Verknuepftes Unternehmen nicht gefunden'); + } + + // RelationshipType validieren + const relType = await this.prisma.relationshipType.findFirst({ + where: { id: dto.relationshipTypeId, tenantId }, + }); + if (!relType) { + throw new NotFoundException('Beziehungstyp nicht gefunden'); + } + + // Duplikat pruefen + const existing = await this.prisma.companyRelationship.findUnique({ + where: { + companyId_relatedCompanyId_relationshipTypeId: { + companyId, + relatedCompanyId: dto.relatedCompanyId, + relationshipTypeId: dto.relationshipTypeId, + }, + }, + }); + if (existing) { + throw new ConflictException('Diese Beziehung existiert bereits'); + } + + return this.prisma.companyRelationship.create({ + data: { + tenantId, + companyId, + relatedCompanyId: dto.relatedCompanyId, + relationshipTypeId: dto.relationshipTypeId, + notes: dto.notes, + createdBy: userId, + }, + include: { + relatedCompany: { + select: { id: true, name: true, industry: true, city: true }, + }, + relationshipType: { + select: { id: true, name: true }, + }, + }, + }); + } + + async findByCompany(tenantId: string, companyId: string) { + // Beziehungen in beide Richtungen laden + const [outgoing, incoming] = await Promise.all([ + this.prisma.companyRelationship.findMany({ + where: { tenantId, companyId }, + include: { + relatedCompany: { + select: { id: true, name: true, industry: true, city: true }, + }, + relationshipType: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.companyRelationship.findMany({ + where: { tenantId, relatedCompanyId: companyId }, + include: { + company: { + select: { id: true, name: true, industry: true, city: true }, + }, + relationshipType: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + // Eingehende Beziehungen umformatieren (damit relatedCompany immer die "andere" Seite ist) + const normalizedIncoming = incoming.map((rel) => ({ + id: rel.id, + tenantId: rel.tenantId, + companyId: rel.relatedCompanyId, + relatedCompanyId: rel.companyId, + relationshipTypeId: rel.relationshipTypeId, + notes: rel.notes, + createdBy: rel.createdBy, + createdAt: rel.createdAt, + relatedCompany: rel.company, + relationshipType: rel.relationshipType, + direction: 'incoming' as const, + })); + + const normalizedOutgoing = outgoing.map((rel) => ({ + ...rel, + direction: 'outgoing' as const, + })); + + return [...normalizedOutgoing, ...normalizedIncoming]; + } + + async remove(tenantId: string, companyId: string, relationshipId: string) { + const rel = await this.prisma.companyRelationship.findFirst({ + where: { + id: relationshipId, + tenantId, + OR: [{ companyId }, { relatedCompanyId: companyId }], + }, + }); + + if (!rel) { + throw new NotFoundException('Beziehung nicht gefunden'); + } + + return this.prisma.companyRelationship.delete({ + where: { id: relationshipId }, + }); + } +} diff --git a/packages/crm-service/src/company-relationships/dto/create-relationship.dto.ts b/packages/crm-service/src/company-relationships/dto/create-relationship.dto.ts new file mode 100644 index 0000000..0e0e785 --- /dev/null +++ b/packages/crm-service/src/company-relationships/dto/create-relationship.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateRelationshipDto { + @ApiProperty({ format: 'uuid', description: 'ID des verknuepften Unternehmens' }) + @IsUUID() + relatedCompanyId!: string; + + @ApiProperty({ format: 'uuid', description: 'ID des Beziehungstyps' }) + @IsUUID() + relationshipTypeId!: string; + + @ApiPropertyOptional({ description: 'Notizen zur Beziehung' }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/packages/crm-service/src/industries/dto/create-industry.dto.ts b/packages/crm-service/src/industries/dto/create-industry.dto.ts new file mode 100644 index 0000000..875b7cd --- /dev/null +++ b/packages/crm-service/src/industries/dto/create-industry.dto.ts @@ -0,0 +1,35 @@ +import { + IsString, + IsOptional, + IsInt, + MaxLength, + Matches, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateIndustryDto { + @ApiProperty({ maxLength: 100, description: 'Name der Branche' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + maxLength: 7, + default: '#6B7280', + description: 'Hex-Farbcode (z.B. #3B82F6)', + }) + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'color muss ein gueltiger Hex-Farbcode sein (z.B. #3B82F6)', + }) + color?: string; + + @ApiPropertyOptional({ default: 0, description: 'Sortierreihenfolge' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/industries/dto/update-industry.dto.ts b/packages/crm-service/src/industries/dto/update-industry.dto.ts new file mode 100644 index 0000000..347d54c --- /dev/null +++ b/packages/crm-service/src/industries/dto/update-industry.dto.ts @@ -0,0 +1,32 @@ +import { + IsString, + IsOptional, + IsInt, + MaxLength, + Matches, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateIndustryDto { + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional({ maxLength: 7 }) + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'color muss ein gueltiger Hex-Farbcode sein (z.B. #3B82F6)', + }) + color?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/industries/industries.controller.ts b/packages/crm-service/src/industries/industries.controller.ts new file mode 100644 index 0000000..91eeb5f --- /dev/null +++ b/packages/crm-service/src/industries/industries.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { IndustriesService } from './industries.service'; +import { CreateIndustryDto } from './dto/create-industry.dto'; +import { UpdateIndustryDto } from './dto/update-industry.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('Industries') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('industries') +export class IndustriesController { + constructor(private readonly industriesService: IndustriesService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Branche erstellen' }) + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateIndustryDto, + ) { + const industry = await this.industriesService.create( + user.tenantId!, + dto, + ); + return singleResponse(industry); + } + + @Get() + @ApiOperation({ summary: 'Alle Branchen auflisten' }) + async findAll(@CurrentUser() user: JwtPayload) { + const industries = await this.industriesService.findAll(user.tenantId!); + return { data: industries }; + } + + @Get(':id') + @ApiOperation({ summary: 'Branche abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const industry = await this.industriesService.findOne( + user.tenantId!, + id, + ); + return singleResponse(industry); + } + + @Patch(':id') + @ApiOperation({ summary: 'Branche aktualisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateIndustryDto, + ) { + const industry = await this.industriesService.update( + user.tenantId!, + id, + dto, + ); + return singleResponse(industry); + } + + @Delete(':id') + @ApiOperation({ summary: 'Branche loeschen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const industry = await this.industriesService.remove( + user.tenantId!, + id, + ); + return singleResponse(industry); + } +} diff --git a/packages/crm-service/src/industries/industries.module.ts b/packages/crm-service/src/industries/industries.module.ts new file mode 100644 index 0000000..c368fd1 --- /dev/null +++ b/packages/crm-service/src/industries/industries.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IndustriesController } from './industries.controller'; +import { IndustriesService } from './industries.service'; + +@Module({ + controllers: [IndustriesController], + providers: [IndustriesService], + exports: [IndustriesService], +}) +export class IndustriesModule {} diff --git a/packages/crm-service/src/industries/industries.service.ts b/packages/crm-service/src/industries/industries.service.ts new file mode 100644 index 0000000..65ec7ba --- /dev/null +++ b/packages/crm-service/src/industries/industries.service.ts @@ -0,0 +1,96 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateIndustryDto } from './dto/create-industry.dto'; +import { UpdateIndustryDto } from './dto/update-industry.dto'; + +@Injectable() +export class IndustriesService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create(tenantId: string, dto: CreateIndustryDto) { + // Pruefen ob Name bereits existiert + const existing = await this.prisma.industry.findUnique({ + where: { tenantId_name: { tenantId, name: dto.name } }, + }); + if (existing) { + throw new ConflictException( + `Branche "${dto.name}" existiert bereits`, + ); + } + + return this.prisma.industry.create({ + data: { + tenantId, + name: dto.name, + color: dto.color ?? '#6B7280', + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async findAll(tenantId: string) { + return this.prisma.industry.findMany({ + where: { tenantId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { companies: true } }, + }, + }); + } + + async findOne(tenantId: string, id: string) { + const industry = await this.prisma.industry.findFirst({ + where: { id, tenantId }, + include: { + _count: { select: { companies: true } }, + }, + }); + + if (!industry) { + throw new NotFoundException('Branche nicht gefunden'); + } + + return industry; + } + + async update(tenantId: string, id: string, dto: UpdateIndustryDto) { + await this.findOne(tenantId, id); + + // Name-Uniqueness pruefen falls Name geaendert wird + if (dto.name) { + const existing = await this.prisma.industry.findFirst({ + where: { tenantId, name: dto.name, NOT: { id } }, + }); + if (existing) { + throw new ConflictException( + `Branche "${dto.name}" existiert bereits`, + ); + } + } + + return this.prisma.industry.update({ + where: { id }, + data: dto, + include: { + _count: { select: { companies: true } }, + }, + }); + } + + async remove(tenantId: string, id: string) { + const industry = await this.findOne(tenantId, id); + + // Pruefen ob noch Unternehmen zugeordnet sind + if (industry._count.companies > 0) { + throw new ConflictException( + `Branche kann nicht geloescht werden — ${industry._count.companies} Unternehmen zugeordnet`, + ); + } + + return this.prisma.industry.delete({ where: { id } }); + } +} diff --git a/packages/crm-service/src/relationship-types/dto/create-relationship-type.dto.ts b/packages/crm-service/src/relationship-types/dto/create-relationship-type.dto.ts new file mode 100644 index 0000000..d90e7a9 --- /dev/null +++ b/packages/crm-service/src/relationship-types/dto/create-relationship-type.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsInt, MaxLength, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateRelationshipTypeDto { + @ApiProperty({ maxLength: 100, description: 'Name des Beziehungstyps' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ default: 0, description: 'Sortierreihenfolge' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/relationship-types/dto/update-relationship-type.dto.ts b/packages/crm-service/src/relationship-types/dto/update-relationship-type.dto.ts new file mode 100644 index 0000000..066094c --- /dev/null +++ b/packages/crm-service/src/relationship-types/dto/update-relationship-type.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional, IsInt, MaxLength, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateRelationshipTypeDto { + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/relationship-types/relationship-types.controller.ts b/packages/crm-service/src/relationship-types/relationship-types.controller.ts new file mode 100644 index 0000000..c081e8c --- /dev/null +++ b/packages/crm-service/src/relationship-types/relationship-types.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { RelationshipTypesService } from './relationship-types.service'; +import { CreateRelationshipTypeDto } from './dto/create-relationship-type.dto'; +import { UpdateRelationshipTypeDto } from './dto/update-relationship-type.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('Relationship Types') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('relationship-types') +export class RelationshipTypesController { + constructor( + private readonly relationshipTypesService: RelationshipTypesService, + ) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Beziehungstyp erstellen' }) + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateRelationshipTypeDto, + ) { + const relType = await this.relationshipTypesService.create( + user.tenantId!, + dto, + ); + return singleResponse(relType); + } + + @Get() + @ApiOperation({ summary: 'Alle Beziehungstypen auflisten' }) + async findAll(@CurrentUser() user: JwtPayload) { + const relTypes = await this.relationshipTypesService.findAll( + user.tenantId!, + ); + return { data: relTypes }; + } + + @Get(':id') + @ApiOperation({ summary: 'Beziehungstyp abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const relType = await this.relationshipTypesService.findOne( + user.tenantId!, + id, + ); + return singleResponse(relType); + } + + @Patch(':id') + @ApiOperation({ summary: 'Beziehungstyp aktualisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateRelationshipTypeDto, + ) { + const relType = await this.relationshipTypesService.update( + user.tenantId!, + id, + dto, + ); + return singleResponse(relType); + } + + @Delete(':id') + @ApiOperation({ summary: 'Beziehungstyp loeschen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const relType = await this.relationshipTypesService.remove( + user.tenantId!, + id, + ); + return singleResponse(relType); + } +} diff --git a/packages/crm-service/src/relationship-types/relationship-types.module.ts b/packages/crm-service/src/relationship-types/relationship-types.module.ts new file mode 100644 index 0000000..12c8d6e --- /dev/null +++ b/packages/crm-service/src/relationship-types/relationship-types.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RelationshipTypesController } from './relationship-types.controller'; +import { RelationshipTypesService } from './relationship-types.service'; + +@Module({ + controllers: [RelationshipTypesController], + providers: [RelationshipTypesService], + exports: [RelationshipTypesService], +}) +export class RelationshipTypesModule {} diff --git a/packages/crm-service/src/relationship-types/relationship-types.service.ts b/packages/crm-service/src/relationship-types/relationship-types.service.ts new file mode 100644 index 0000000..a8b1b80 --- /dev/null +++ b/packages/crm-service/src/relationship-types/relationship-types.service.ts @@ -0,0 +1,92 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateRelationshipTypeDto } from './dto/create-relationship-type.dto'; +import { UpdateRelationshipTypeDto } from './dto/update-relationship-type.dto'; + +@Injectable() +export class RelationshipTypesService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create(tenantId: string, dto: CreateRelationshipTypeDto) { + const existing = await this.prisma.relationshipType.findUnique({ + where: { tenantId_name: { tenantId, name: dto.name } }, + }); + if (existing) { + throw new ConflictException( + `Beziehungstyp "${dto.name}" existiert bereits`, + ); + } + + return this.prisma.relationshipType.create({ + data: { + tenantId, + name: dto.name, + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async findAll(tenantId: string) { + return this.prisma.relationshipType.findMany({ + where: { tenantId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { relationships: true } }, + }, + }); + } + + async findOne(tenantId: string, id: string) { + const relType = await this.prisma.relationshipType.findFirst({ + where: { id, tenantId }, + include: { + _count: { select: { relationships: true } }, + }, + }); + + if (!relType) { + throw new NotFoundException('Beziehungstyp nicht gefunden'); + } + + return relType; + } + + async update(tenantId: string, id: string, dto: UpdateRelationshipTypeDto) { + await this.findOne(tenantId, id); + + if (dto.name) { + const existing = await this.prisma.relationshipType.findFirst({ + where: { tenantId, name: dto.name, NOT: { id } }, + }); + if (existing) { + throw new ConflictException( + `Beziehungstyp "${dto.name}" existiert bereits`, + ); + } + } + + return this.prisma.relationshipType.update({ + where: { id }, + data: dto, + include: { + _count: { select: { relationships: true } }, + }, + }); + } + + async remove(tenantId: string, id: string) { + const relType = await this.findOne(tenantId, id); + + if (relType._count.relationships > 0) { + throw new ConflictException( + `Beziehungstyp kann nicht geloescht werden — ${relType._count.relationships} Beziehungen zugeordnet`, + ); + } + + return this.prisma.relationshipType.delete({ where: { id } }); + } +} diff --git a/packages/frontend/src/crm/activities/ActivityFormModal.tsx b/packages/frontend/src/crm/activities/ActivityFormModal.tsx index fd7863a..7898bed 100644 --- a/packages/frontend/src/crm/activities/ActivityFormModal.tsx +++ b/packages/frontend/src/crm/activities/ActivityFormModal.tsx @@ -6,7 +6,8 @@ import type { Activity, ActivityType } from '../types'; interface ActivityFormModalProps { isOpen: boolean; onClose: () => void; - contactId: string; + contactId?: string; + companyId?: string; activity?: Activity | null; onSuccess: () => void; } @@ -51,6 +52,7 @@ export function ActivityFormModal({ isOpen, onClose, contactId, + companyId, activity, onSuccess, }: ActivityFormModalProps) { @@ -127,7 +129,8 @@ export function ActivityFormModal({ } else { createMutation.mutate( { - contactId, + ...(contactId ? { contactId } : {}), + ...(companyId ? { companyId } : {}), type, subject: subject.trim(), ...(description ? { description } : {}), diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 0b73379..ce6cbe4 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -26,6 +26,18 @@ import type { CreateCompanyPayload, UpdateCompanyPayload, CompaniesQueryParams, + Industry, + CreateIndustryPayload, + UpdateIndustryPayload, + AccountType, + CreateAccountTypePayload, + UpdateAccountTypePayload, + RelationshipType, + CreateRelationshipTypePayload, + UpdateRelationshipTypePayload, + CompanyRelationship, + CreateCompanyRelationshipPayload, + TenantUser, LexwareContact, LexwareContactSearchParams, LexwareVoucher, @@ -205,6 +217,126 @@ export const companiesApi = { .then((r) => r.data), }; +// --- Industries --- + +export const industriesApi = { + list: () => + api + .get<{ success: boolean; data: Industry[]; meta: { timestamp: string } }>( + '/crm/industries', + ) + .then((r) => r.data), + + create: (data: CreateIndustryPayload) => + api + .post>('/crm/industries', data) + .then((r) => r.data), + + update: (id: string, data: UpdateIndustryPayload) => + api + .patch>(`/crm/industries/${id}`, data) + .then((r) => r.data), + + delete: (id: string) => + api + .delete>(`/crm/industries/${id}`) + .then((r) => r.data), +}; + +// --- Account Types --- + +export const accountTypesApi = { + list: () => + api + .get<{ success: boolean; data: AccountType[]; meta: { timestamp: string } }>( + '/crm/account-types', + ) + .then((r) => r.data), + + create: (data: CreateAccountTypePayload) => + api + .post>('/crm/account-types', data) + .then((r) => r.data), + + update: (id: string, data: UpdateAccountTypePayload) => + api + .patch>(`/crm/account-types/${id}`, data) + .then((r) => r.data), + + delete: (id: string) => + api + .delete>(`/crm/account-types/${id}`) + .then((r) => r.data), +}; + +// --- Relationship Types --- + +export const relationshipTypesApi = { + list: () => + api + .get<{ success: boolean; data: RelationshipType[]; meta: { timestamp: string } }>( + '/crm/relationship-types', + ) + .then((r) => r.data), + + create: (data: CreateRelationshipTypePayload) => + api + .post>('/crm/relationship-types', data) + .then((r) => r.data), + + update: (id: string, data: UpdateRelationshipTypePayload) => + api + .patch>( + `/crm/relationship-types/${id}`, + data, + ) + .then((r) => r.data), + + delete: (id: string) => + api + .delete>( + `/crm/relationship-types/${id}`, + ) + .then((r) => r.data), +}; + +// --- Company Relationships --- + +export const companyRelationshipsApi = { + list: (companyId: string) => + api + .get<{ success: boolean; data: CompanyRelationship[]; meta: { timestamp: string } }>( + `/crm/companies/${companyId}/relationships`, + ) + .then((r) => r.data), + + create: (companyId: string, data: CreateCompanyRelationshipPayload) => + api + .post>( + `/crm/companies/${companyId}/relationships`, + data, + ) + .then((r) => r.data), + + delete: (companyId: string, relationshipId: string) => + api + .delete>( + `/crm/companies/${companyId}/relationships/${relationshipId}`, + ) + .then((r) => r.data), +}; + +// --- Tenant Users --- + +export const usersApi = { + list: () => + api + .get<{ success: boolean; data: TenantUser[]; meta: { timestamp: string } }>( + '/crm/users', + ) + .then((r) => r.data), +}; + // --- Lexware Office: Contacts --- export const lexwareContactsApi = { diff --git a/packages/frontend/src/crm/companies/ActivityFeed.tsx b/packages/frontend/src/crm/companies/ActivityFeed.tsx new file mode 100644 index 0000000..677c91d --- /dev/null +++ b/packages/frontend/src/crm/companies/ActivityFeed.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react'; +import { useCompanyActivities, useCreateActivity } from '../hooks'; +import type { Activity, ActivityType } from '../types'; +import styles from './CompanyDetailPage.module.css'; + +// ============================================================ +// Constants +// ============================================================ + +const ACTIVITY_TYPE_LABELS: Record = { + NOTE: 'Notiz', + CALL: 'Anruf', + EMAIL: 'E-Mail', + MEETING: 'Meeting', + TASK: 'Aufgabe', +}; + +const iconSvgProps = { + width: 14, + height: 14, + stroke: 'currentColor', + fill: 'none', + strokeWidth: 1.5, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, +}; + +function ActivityIcon({ type }: { type: ActivityType }) { + switch (type) { + case 'NOTE': + return ( + + + + + ); + case 'CALL': + return ( + + + + ); + case 'EMAIL': + return ( + + + + + ); + case 'MEETING': + return ( + + + + + + ); + case 'TASK': + return ( + + + + + ); + default: + return null; + } +} + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function contactDisplayName(a: Activity): string | null { + if (!a.contact) return null; + const parts = [a.contact.firstName, a.contact.lastName].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : a.contact.companyName ?? null; +} + +// ============================================================ +// Feed Tabs +// ============================================================ + +type FeedTab = 'NOTE' | 'EMAIL' | 'TASK'; + +const FEED_TABS: { key: FeedTab; label: string; enabled: boolean }[] = [ + { key: 'NOTE', label: 'Notiz', enabled: true }, + { key: 'EMAIL', label: 'E-Mail', enabled: false }, + { key: 'TASK', label: 'Aufgabe', enabled: false }, +]; + +// ============================================================ +// Component +// ============================================================ + +interface ActivityFeedProps { + companyId: string; +} + +export function ActivityFeed({ companyId }: ActivityFeedProps) { + const { data, isLoading } = useCompanyActivities(companyId); + const createMut = useCreateActivity(); + + const [activeTab, setActiveTab] = useState('NOTE'); + const [subject, setSubject] = useState(''); + const [description, setDescription] = useState(''); + + const activities: Activity[] = data?.data ?? []; + + const handleSubmit = () => { + if (!subject.trim()) return; + createMut.mutate( + { + companyId, + type: activeTab as ActivityType, + subject: subject.trim(), + ...(description.trim() ? { description: description.trim() } : {}), + }, + { + onSuccess: () => { + setSubject(''); + setDescription(''); + }, + }, + ); + }; + + return ( +
+

Aktivitäten

+ + {/* Input Area */} +
+
+ {FEED_TABS.map((tab) => ( + + ))} +
+ +
+ setSubject(e.target.value)} + placeholder="Betreff..." + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + /> +