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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-11 09:21:57 +01:00
parent 4e5c26cadd
commit 0ed1e77844
41 changed files with 3295 additions and 235 deletions

View file

@ -1,6 +1,6 @@
# CRM-Service - Zusammenfassung # CRM-Service - Zusammenfassung
## Stand: 2026-03-10 ## Stand: 2026-03-11
### Was wurde erstellt ### Was wurde erstellt
@ -26,13 +26,17 @@ packages/crm-service/
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks) redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter 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) 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) pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers 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) health/ — Health-Check (DB, Redis, Lexware)
lexware/ — Lexware Office Integration (NEU) lexware/ — Lexware Office Integration
lexware.module.ts — Feature Module (HttpModule + ScheduleModule) lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s) lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync
@ -49,22 +53,32 @@ packages/crm-service/
### Datenbank-Modelle (app_crm Schema) ### 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 - **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 - **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
- **PipelineStage** — Stufen innerhalb einer Pipeline - **PipelineStage** — Stufen innerhalb einer Pipeline
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen - **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften) - **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant)
- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) - **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 ### Entity-Beziehungen
``` ```
Company (1) --< (n) Contact — companyId (optional, SetNull) Company (1) --< (n) Contact — companyId (optional, SetNull)
Company (1) --< (n) Deal — 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 (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
Contact (1) --< (n) Activity — contactId (Cascade) 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) Deal — contactId (optional, SetNull)
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull) Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade) Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
@ -72,6 +86,7 @@ Pipeline (1) --< (n) Deal — pipelineId (Cascade)
PipelineStage (1) --< (n) Deal — stageId PipelineStage (1) --< (n) Deal — stageId
Deal (1) --< (n) DealVoucher — dealId (Cascade) Deal (1) --< (n) DealVoucher — dealId (Cascade)
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
``` ```
### API-Endpunkte ### API-Endpunkte
@ -82,8 +97,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
| GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete | | GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete |
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen | | GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete | | 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/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/POST | /api/v1/crm/pipelines | Liste / Erstellen |
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete | | GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen | | POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
@ -141,14 +165,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
- Prisma Migrationen: - Prisma Migrationen:
- `20260310163211_init` — Initiales Schema - `20260310163211_init` — Initiales Schema
- `20260310183117_add_companies` — Company-Entity - `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 ### Naechste Schritte
1. Migration `20260310_add_lexware_integration` auf Server anwenden 1. Migration `20260311_add_company_detail_overhaul` auf Server anwenden
2. `LEXWARE_API_KEY` in `.env` auf Server setzen 2. Seed-Data (Industries, AccountTypes, RelationshipTypes) ausfuehren
3. Container neu bauen und deployen 3. Container neu bauen und deployen
4. Lexware-Endpunkte auf Server testen 4. Frontend testen: CompanyDetailPage 3-Spalten Layout
5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten 5. CRM-Einstellungen: Branchen/Kontotypen/Beziehungstypen verwalten
6. Activity-Liste komplett laden (UI-Button "Alle anzeigen") 6. CompanyFormModal: Dropdowns testen
7. Kanban-Board fuer Vorgaenge 7. Activity Feed: Aggregierten Feed testen
8. Kanban-Board fuer Vorgaenge
9. Vertraege-UI implementieren (DB-Modell bereits vorhanden)

View file

@ -17,6 +17,64 @@ datasource db {
schemas = ["app_crm"] 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) // Company - Unternehmen (uebergeordnete Entity)
// -------------------------------------------------------- // --------------------------------------------------------
@ -27,6 +85,12 @@ model Company {
name String @db.VarChar(200) name String @db.VarChar(200)
industry String? @db.VarChar(100) 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 // Kontaktdaten
email String? @db.VarChar(255) email String? @db.VarChar(255)
phone String? @db.VarChar(50) phone String? @db.VarChar(50)
@ -58,14 +122,22 @@ model Company {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
// Relationen // Relationen
industryRef Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull)
accountType AccountType? @relation(fields: [accountTypeId], references: [id], onDelete: SetNull)
contacts Contact[] contacts Contact[]
deals Deal[] deals Deal[]
activities Activity[]
lexwareVouchers LexwareVoucher[] lexwareVouchers LexwareVoucher[]
relationships CompanyRelationship[] @relation("companyRelationships")
relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships")
contracts Contract[]
@@unique([tenantId, lexwareContactId]) @@unique([tenantId, lexwareContactId])
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, name]) @@index([tenantId, name])
@@index([tenantId, industry]) @@index([tenantId, industry])
@@index([tenantId, industryId])
@@index([tenantId, accountTypeId])
@@index([tenantId, isActive]) @@index([tenantId, isActive])
@@map("companies") @@map("companies")
@@schema("app_crm") @@schema("app_crm")
@ -149,7 +221,8 @@ enum ContactType {
model Activity { model Activity {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @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 type ActivityType
subject String @db.VarChar(500) subject String @db.VarChar(500)
description String? @db.Text description String? @db.Text
@ -166,10 +239,12 @@ model Activity {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
// Relationen // 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])
@@index([tenantId, contactId]) @@index([tenantId, contactId])
@@index([tenantId, companyId])
@@index([tenantId, type]) @@index([tenantId, type])
@@index([tenantId, scheduledAt]) @@index([tenantId, scheduledAt])
@@map("activities") @@map("activities")
@ -186,6 +261,67 @@ enum ActivityType {
@@schema("app_crm") @@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) // Pipeline - Sales-Pipelines (konfigurierbar pro Tenant)
// -------------------------------------------------------- // --------------------------------------------------------

View file

@ -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" != '';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateActivityDto } from './dto/create-activity.dto'; import { CreateActivityDto } from './dto/create-activity.dto';
import { UpdateActivityDto } from './dto/update-activity.dto'; import { UpdateActivityDto } from './dto/update-activity.dto';
@ -10,18 +14,38 @@ export class ActivitiesService {
constructor(private readonly prisma: CrmPrismaService) {} constructor(private readonly prisma: CrmPrismaService) {}
async create(tenantId: string, userId: string, dto: CreateActivityDto) { async create(tenantId: string, userId: string, dto: CreateActivityDto) {
// Pruefen ob der Kontakt existiert und zum Tenant gehoert // 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({ const contact = await this.prisma.contact.findFirst({
where: { id: dto.contactId, tenantId }, where: { id: dto.contactId, tenantId },
}); });
if (!contact) { if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden'); 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({ return this.prisma.activity.create({
data: { data: {
tenantId, tenantId,
contactId: dto.contactId, contactId: dto.contactId,
companyId: dto.companyId,
type: dto.type, type: dto.type,
subject: dto.subject, subject: dto.subject,
description: dto.description, description: dto.description,
@ -29,7 +53,14 @@ export class ActivitiesService {
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined, completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
createdBy: userId, 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 }; 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; where.contactId = query.contactId;
} }
if (query.type) { if (query.type) {
where.type = query.type; where.type = query.type;
} }
@ -57,7 +106,14 @@ export class ActivitiesService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' }, 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 }), this.prisma.activity.count({ where }),
]); ]);
@ -68,7 +124,10 @@ export class ActivitiesService {
async findOne(tenantId: string, id: string) { async findOne(tenantId: string, id: string) {
const activity = await this.prisma.activity.findFirst({ const activity = await this.prisma.activity.findFirst({
where: { id, tenantId }, where: { id, tenantId },
include: { contact: true }, include: {
contact: true,
company: { select: { id: true, name: true } },
},
}); });
if (!activity) { if (!activity) {
@ -94,7 +153,14 @@ export class ActivitiesService {
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined, completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
updatedBy: userId, updatedBy: userId,
}, },
include: { contact: true }, include: {
contact: {
select: { id: true, firstName: true, lastName: true, companyName: true },
},
company: {
select: { id: true, name: true },
},
},
}); });
} }

View file

@ -5,6 +5,7 @@ import {
IsUUID, IsUUID,
IsDateString, IsDateString,
MaxLength, MaxLength,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -17,9 +18,17 @@ export enum ActivityType {
} }
export class CreateActivityDto { export class CreateActivityDto {
@ApiProperty({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid', description: 'Kontakt-ID (optional wenn companyId gesetzt)' })
@IsOptional()
@ValidateIf((o) => !o.companyId)
@IsUUID() @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 }) @ApiProperty({ enum: ActivityType })
@IsEnum(ActivityType) @IsEnum(ActivityType)

View file

@ -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 { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { ActivityType } from './create-activity.dto'; import { ActivityType } from './create-activity.dto';
@ -9,6 +10,20 @@ export class QueryActivitiesDto extends PaginationDto {
@IsUUID() @IsUUID()
contactId?: string; 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 }) @ApiPropertyOptional({ enum: ActivityType })
@IsOptional() @IsOptional()
@IsEnum(ActivityType) @IsEnum(ActivityType)

View file

@ -15,6 +15,10 @@ import { PipelinesModule } from './pipelines/pipelines.module';
import { DealsModule } from './deals/deals.module'; import { DealsModule } from './deals/deals.module';
import { CompaniesModule } from './companies/companies.module'; import { CompaniesModule } from './companies/companies.module';
import { LexwareModule } from './lexware/lexware.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({ @Module({
imports: [ imports: [
@ -33,6 +37,10 @@ import { LexwareModule } from './lexware/lexware.module';
DealsModule, DealsModule,
CompaniesModule, CompaniesModule,
LexwareModule, LexwareModule,
IndustriesModule,
AccountTypesModule,
RelationshipTypesModule,
CompanyRelationshipsModule,
], ],
providers: [ providers: [
{ {

View file

@ -22,6 +22,10 @@ export class CompaniesService {
createdBy: userId, createdBy: userId,
name: dto.name, name: dto.name,
industry: dto.industry, industry: dto.industry,
industryId: dto.industryId,
accountTypeId: dto.accountTypeId,
ownerId: dto.ownerId,
ownerName: dto.ownerName,
email: dto.email, email: dto.email,
phone: dto.phone, phone: dto.phone,
website: dto.website, website: dto.website,
@ -35,6 +39,8 @@ export class CompaniesService {
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
}, },
include: { include: {
industryRef: true,
accountType: true,
_count: { select: { contacts: true, deals: true } }, _count: { select: { contacts: true, deals: true } },
}, },
}); });
@ -77,6 +83,8 @@ export class CompaniesService {
take: pageSize, take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' }, orderBy: { [sortField]: query.order ?? 'desc' },
include: { include: {
industryRef: true,
accountType: true,
_count: { select: { contacts: true, deals: true } }, _count: { select: { contacts: true, deals: true } },
}, },
}), }),
@ -90,6 +98,8 @@ export class CompaniesService {
const company = await this.prisma.company.findFirst({ const company = await this.prisma.company.findFirst({
where: { id, tenantId }, where: { id, tenantId },
include: { include: {
industryRef: true,
accountType: true,
contacts: { contacts: {
where: { isActive: true }, where: { isActive: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@ -112,8 +122,34 @@ export class CompaniesService {
stage: { select: { id: true, name: true, color: true } }, 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: { _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, updatedBy: userId,
}, },
include: { include: {
industryRef: true,
accountType: true,
_count: { select: { contacts: true, deals: true } }, _count: { select: { contacts: true, deals: true } },
}, },
}); });

View file

@ -4,6 +4,7 @@ import {
IsBoolean, IsBoolean,
IsEmail, IsEmail,
IsUrl, IsUrl,
IsUUID,
IsArray, IsArray,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@ -15,12 +16,33 @@ export class CreateCompanyDto {
@MaxLength(200) @MaxLength(200)
name!: string; name!: string;
@ApiPropertyOptional({ maxLength: 100 }) @ApiPropertyOptional({ maxLength: 100, description: 'Freitext-Branche (Legacy)' })
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
industry?: string; 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 }) @ApiPropertyOptional({ maxLength: 255 })
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()

View file

@ -4,6 +4,7 @@ import {
IsBoolean, IsBoolean,
IsEmail, IsEmail,
IsUrl, IsUrl,
IsUUID,
IsArray, IsArray,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@ -22,6 +23,27 @@ export class UpdateCompanyDto {
@MaxLength(100) @MaxLength(100)
industry?: string; 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 }) @ApiPropertyOptional({ maxLength: 255 })
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,8 @@ import type { Activity, ActivityType } from '../types';
interface ActivityFormModalProps { interface ActivityFormModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
contactId: string; contactId?: string;
companyId?: string;
activity?: Activity | null; activity?: Activity | null;
onSuccess: () => void; onSuccess: () => void;
} }
@ -51,6 +52,7 @@ export function ActivityFormModal({
isOpen, isOpen,
onClose, onClose,
contactId, contactId,
companyId,
activity, activity,
onSuccess, onSuccess,
}: ActivityFormModalProps) { }: ActivityFormModalProps) {
@ -127,7 +129,8 @@ export function ActivityFormModal({
} else { } else {
createMutation.mutate( createMutation.mutate(
{ {
contactId, ...(contactId ? { contactId } : {}),
...(companyId ? { companyId } : {}),
type, type,
subject: subject.trim(), subject: subject.trim(),
...(description ? { description } : {}), ...(description ? { description } : {}),

View file

@ -26,6 +26,18 @@ import type {
CreateCompanyPayload, CreateCompanyPayload,
UpdateCompanyPayload, UpdateCompanyPayload,
CompaniesQueryParams, CompaniesQueryParams,
Industry,
CreateIndustryPayload,
UpdateIndustryPayload,
AccountType,
CreateAccountTypePayload,
UpdateAccountTypePayload,
RelationshipType,
CreateRelationshipTypePayload,
UpdateRelationshipTypePayload,
CompanyRelationship,
CreateCompanyRelationshipPayload,
TenantUser,
LexwareContact, LexwareContact,
LexwareContactSearchParams, LexwareContactSearchParams,
LexwareVoucher, LexwareVoucher,
@ -205,6 +217,126 @@ export const companiesApi = {
.then((r) => r.data), .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<SingleResponse<Industry>>('/crm/industries', data)
.then((r) => r.data),
update: (id: string, data: UpdateIndustryPayload) =>
api
.patch<SingleResponse<Industry>>(`/crm/industries/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<Industry>>(`/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<SingleResponse<AccountType>>('/crm/account-types', data)
.then((r) => r.data),
update: (id: string, data: UpdateAccountTypePayload) =>
api
.patch<SingleResponse<AccountType>>(`/crm/account-types/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<AccountType>>(`/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<SingleResponse<RelationshipType>>('/crm/relationship-types', data)
.then((r) => r.data),
update: (id: string, data: UpdateRelationshipTypePayload) =>
api
.patch<SingleResponse<RelationshipType>>(
`/crm/relationship-types/${id}`,
data,
)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<RelationshipType>>(
`/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<SingleResponse<CompanyRelationship>>(
`/crm/companies/${companyId}/relationships`,
data,
)
.then((r) => r.data),
delete: (companyId: string, relationshipId: string) =>
api
.delete<SingleResponse<CompanyRelationship>>(
`/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 --- // --- Lexware Office: Contacts ---
export const lexwareContactsApi = { export const lexwareContactsApi = {

View file

@ -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<ActivityType, string> = {
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 (
<svg {...iconSvgProps} viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="1" />
<path d="M5 5h6M5 8h6M5 11h3" />
</svg>
);
case 'CALL':
return (
<svg {...iconSvgProps} viewBox="0 0 16 16">
<path d="M3 2h3l1.5 3L6 6.5c.7 1.4 2.1 2.8 3.5 3.5L11 8.5l3 1.5v3a1 1 0 01-1 1C7 14 2 9 2 3a1 1 0 011-1z" />
</svg>
);
case 'EMAIL':
return (
<svg {...iconSvgProps} viewBox="0 0 16 16">
<rect x="1" y="3" width="14" height="10" rx="1" />
<path d="M1 4l7 5 7-5" />
</svg>
);
case 'MEETING':
return (
<svg {...iconSvgProps} viewBox="0 0 16 16">
<circle cx="5" cy="5" r="2" />
<circle cx="11" cy="5" r="2" />
<path d="M1 12c0-2 2-3 4-3s4 1 4 3M7 12c0-2 2-3 4-3s4 1 4 3" />
</svg>
);
case 'TASK':
return (
<svg {...iconSvgProps} viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="1" />
<path d="M5 8l2 2 4-4" />
</svg>
);
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<FeedTab>('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 (
<div className={styles.card}>
<h2 className={styles.cardTitle}>Aktivitäten</h2>
{/* Input Area */}
<div className={styles.feedInputArea}>
<div className={styles.feedTabs}>
{FEED_TABS.map((tab) => (
<button
key={tab.key}
className={`${styles.feedTab} ${activeTab === tab.key ? styles.feedTabActive : ''}`}
onClick={() => tab.enabled && setActiveTab(tab.key)}
disabled={!tab.enabled}
style={!tab.enabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
title={!tab.enabled ? 'Bald verfügbar' : undefined}
>
{tab.label}
</button>
))}
</div>
<div className={styles.feedForm}>
<input
className={styles.feedSubjectInput}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Betreff..."
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
<textarea
className={styles.feedTextarea}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Beschreibung (optional)..."
rows={2}
/>
<div className={styles.feedSubmitRow}>
<button
className={styles.feedSubmitBtn}
onClick={handleSubmit}
disabled={!subject.trim() || createMut.isPending}
>
{createMut.isPending ? 'Speichern...' : 'Hinzufügen'}
</button>
</div>
</div>
</div>
{/* Activity List */}
{isLoading ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
Laden...
</p>
) : activities.length === 0 ? (
<div className={styles.feedEmpty}>
Noch keine Aktivitäten vorhanden
</div>
) : (
<div className={styles.feedList}>
{activities.map((activity) => {
const viaName = contactDisplayName(activity);
return (
<div key={activity.id} className={styles.feedItem}>
<div className={styles.feedIcon}>
<ActivityIcon type={activity.type} />
</div>
<div className={styles.feedContent}>
<div className={styles.feedSubject}>{activity.subject}</div>
<div className={styles.feedMeta}>
<span>{ACTIVITY_TYPE_LABELS[activity.type]}</span>
<span>·</span>
<span>{formatDateTime(activity.createdAt)}</span>
{viaName && activity.contactId && !activity.companyId && (
<span className={styles.feedViaBadge}>
via {viaName}
</span>
)}
</div>
{activity.description && (
<div className={styles.feedDesc}>
{activity.description}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
/* ============================================================ /* ============================================================
CompanyDetailPage Layout & Komponenten CompanyDetailPage 3-Spalten Layout & Komponenten
============================================================ */ ============================================================ */
.backLink { .backLink {
@ -39,19 +39,45 @@
color: var(--color-text); color: var(--color-text);
} }
.industryBadge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: white;
}
/* ============================================================
3-Spalten Grid
============================================================ */
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 300px 1fr 360px;
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
@media (max-width: 960px) { @media (max-width: 1200px) {
.layout {
grid-template-columns: 1fr 360px;
}
.layout > :first-child {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ============================================================
Cards
============================================================ */
.card { .card {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@ -67,21 +93,38 @@
color: var(--color-text); color: var(--color-text);
} }
.cardTitleRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.cardTitleRow h2 {
margin: 0;
}
/* ============================================================
Info Grid (linke Spalte)
============================================================ */
.infoGrid { .infoGrid {
display: grid; display: grid;
grid-template-columns: 120px 1fr; grid-template-columns: 100px 1fr;
gap: 0.5rem 1rem; gap: 0.5rem 0.75rem;
font-size: 0.875rem; font-size: 0.8125rem;
} }
.infoLabel { .infoLabel {
color: var(--color-text-muted); color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
font-size: 0.8125rem;
} }
.infoValue { .infoValue {
color: var(--color-text); color: var(--color-text);
word-break: break-word; word-break: break-word;
font-size: 0.8125rem;
} }
.tags { .tags {
@ -96,7 +139,7 @@
background: var(--color-bg); background: var(--color-bg);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 9999px; border-radius: 9999px;
font-size: 0.75rem; font-size: 0.6875rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@ -107,9 +150,339 @@
} }
.notesText { .notesText {
font-size: 0.875rem; font-size: 0.8125rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }
/* ============================================================
Activity Feed (mittlere Spalte)
============================================================ */
.feedContainer {
display: flex;
flex-direction: column;
height: 100%;
}
.feedInputArea {
margin-bottom: 1rem;
}
.feedTabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.75rem;
}
.feedTab {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.feedTab:hover {
color: var(--color-text);
}
.feedTabActive {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.feedForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feedSubjectInput {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
outline: none;
background: var(--color-bg-card);
color: var(--color-text);
}
.feedSubjectInput:focus {
border-color: var(--color-primary);
}
.feedTextarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
outline: none;
resize: vertical;
min-height: 60px;
background: var(--color-bg-card);
color: var(--color-text);
}
.feedTextarea:focus {
border-color: var(--color-primary);
}
.feedSubmitRow {
display: flex;
justify-content: flex-end;
}
.feedSubmitBtn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s;
}
.feedSubmitBtn:hover {
opacity: 0.9;
}
.feedSubmitBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feedList {
display: flex;
flex-direction: column;
gap: 0;
max-height: 600px;
overflow-y: auto;
}
.feedItem {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.feedItem:last-child {
border-bottom: none;
}
.feedIcon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
.feedContent {
flex: 1;
min-width: 0;
}
.feedSubject {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.feedMeta {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.125rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.feedViaBadge {
display: inline-block;
padding: 0 0.375rem;
border-radius: 9999px;
font-size: 0.6875rem;
background: #e0e7ff;
color: #3730a3;
font-weight: 500;
}
:global([data-theme='dark']) .feedViaBadge {
background: #312e81;
color: #c7d2fe;
}
.feedDesc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-top: 0.375rem;
white-space: pre-wrap;
}
.feedEmpty {
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* ============================================================
Relationships Card (rechte Spalte)
============================================================ */
.relationItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.relationItem:last-child {
border-bottom: none;
}
.relationInfo {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.relationName {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-primary);
cursor: pointer;
}
.relationName:hover {
text-decoration: underline;
}
.relationTypeBadge {
display: inline-block;
padding: 0 0.375rem;
border-radius: 9999px;
font-size: 0.6875rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.relationDeleteBtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.relationDeleteBtn:hover {
background: #fef2f2;
color: #dc2626;
}
:global([data-theme='dark']) .relationDeleteBtn:hover {
background: #450a0a;
color: #fca5a5;
}
/* ============================================================
Placeholder Card
============================================================ */
.placeholderCard {
opacity: 0.6;
text-align: center;
padding: 1.5rem;
}
.placeholderCard p {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin: 0;
}
/* ============================================================
Small Add Button in Cards
============================================================ */
.smallAddBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-primary);
background: transparent;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.smallAddBtn:hover {
background: var(--color-primary);
color: white;
}
/* ============================================================
Compact Table (rechte Spalte)
============================================================ */
.compactTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.compactTable th {
text-align: left;
padding: 0.375rem 0;
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
font-weight: 500;
}
.compactTable td {
padding: 0.375rem 0;
border-bottom: 1px solid var(--color-border);
}
.compactTable tr:last-child td {
border-bottom: none;
}
.compactTable tr[data-clickable='true'] {
cursor: pointer;
}
.compactTable tr[data-clickable='true']:hover td {
background: var(--color-bg);
}

View file

@ -2,11 +2,18 @@ import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCompany, useDeleteCompany } from '../hooks'; import { useCompany, useDeleteCompany } from '../hooks';
import { CompanyFormModal } from './CompanyFormModal'; import { CompanyFormModal } from './CompanyFormModal';
import { ActivityFeed } from './ActivityFeed';
import { CompanyRelationshipsCard } from './CompanyRelationshipsCard';
import { ContractsCard } from './ContractsCard';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { LexwareSection } from '../lexware/LexwareSection'; import { LexwareSection } from '../lexware/LexwareSection';
import type { DealStatus } from '../types'; import type { DealStatus } from '../types';
import styles from './CompanyDetailPage.module.css'; import styles from './CompanyDetailPage.module.css';
// ============================================================
// Constants
// ============================================================
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = { const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
OPEN: { bg: '#dbeafe', color: '#1e40af' }, OPEN: { bg: '#dbeafe', color: '#1e40af' },
WON: { bg: '#d1fae5', color: '#065f46' }, WON: { bg: '#d1fae5', color: '#065f46' },
@ -32,6 +39,10 @@ function formatDate(iso: string): string {
}); });
} }
// ============================================================
// Page Component
// ============================================================
export function CompanyDetailPage() { export function CompanyDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -53,6 +64,10 @@ export function CompanyDetailPage() {
const contacts = company.contacts ?? []; const contacts = company.contacts ?? [];
const deals = company.deals ?? []; const deals = company.deals ?? [];
// Industry badge color
const industryColor = company.industryRef?.color ?? '#6366f1';
const industryName = company.industryRef?.name ?? company.industry;
return ( return (
<div> <div>
{/* Zurück */} {/* Zurück */}
@ -74,19 +89,12 @@ export function CompanyDetailPage() {
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<h1 className={styles.name}>{company.name}</h1> <h1 className={styles.name}>{company.name}</h1>
{company.industry && ( {industryName && (
<span <span
style={{ className={styles.industryBadge}
display: 'inline-block', style={{ backgroundColor: industryColor }}
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
background: '#e0e7ff',
color: '#3730a3',
}}
> >
{company.industry} {industryName}
</span> </span>
)} )}
<span <span
@ -134,13 +142,25 @@ export function CompanyDetailPage() {
</div> </div>
</div> </div>
{/* 2-Spalten Layout */} {/* 3-Spalten Layout */}
<div className={styles.layout}> <div className={styles.layout}>
{/* Links: Info */} {/* ======== Linke Spalte: Stammdaten ======== */}
<div> <div>
<div className={styles.card}> <div className={styles.card}>
<h2 className={styles.cardTitle}>Unternehmensdaten</h2> <h2 className={styles.cardTitle}>Unternehmensdaten</h2>
<div className={styles.infoGrid}> <div className={styles.infoGrid}>
{company.accountType && (
<>
<span className={styles.infoLabel}>Kontotyp</span>
<span className={styles.infoValue}>{company.accountType.name}</span>
</>
)}
{company.ownerName && (
<>
<span className={styles.infoLabel}>Zuständig</span>
<span className={styles.infoValue}>{company.ownerName}</span>
</>
)}
{company.email && ( {company.email && (
<> <>
<span className={styles.infoLabel}>E-Mail</span> <span className={styles.infoLabel}>E-Mail</span>
@ -187,7 +207,7 @@ export function CompanyDetailPage() {
</span> </span>
</> </>
)} )}
<span className={styles.infoLabel}>Erstellt am</span> <span className={styles.infoLabel}>Erstellt</span>
<span className={styles.infoValue}> <span className={styles.infoValue}>
{formatDate(company.createdAt)} {formatDate(company.createdAt)}
</span> </span>
@ -227,59 +247,30 @@ export function CompanyDetailPage() {
</div> </div>
</div> </div>
{/* Rechts: Kontakte + Vorgänge + Lexware */} {/* ======== Mittlere Spalte: Activity Feed ======== */}
<div>
<ActivityFeed companyId={company.id} />
</div>
{/* ======== Rechte Spalte: Relations ======== */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Kontakte */} {/* Kontakte */}
<div className={styles.card}> <div className={styles.card}>
<h2 className={styles.cardTitle}> <div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Kontakte ({company._count?.contacts ?? contacts.length}) Kontakte ({company._count?.contacts ?? contacts.length})
</h2> </h2>
</div>
{contacts.length === 0 ? ( {contacts.length === 0 ? (
<p <p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
Keine Kontakte vorhanden Keine Kontakte vorhanden
</p> </p>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table className={styles.compactTable}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}> <tr>
<th <th>Name</th>
style={{ <th>Position</th>
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Name
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Position
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
E-Mail
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -290,38 +281,13 @@ export function CompanyDetailPage() {
return ( return (
<tr <tr
key={c.id} key={c.id}
style={{ data-clickable="true"
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
onClick={() => navigate(`/crm/contacts/${c.id}`)} onClick={() => navigate(`/crm/contacts/${c.id}`)}
> >
<td <td style={{ fontWeight: 500 }}>{name}</td>
style={{ <td style={{ color: 'var(--color-text-secondary)' }}>
padding: '0.5rem 0',
fontSize: '0.875rem',
}}
>
{name}
</td>
<td
style={{
padding: '0.5rem 0',
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
}}
>
{c.position ?? '—'} {c.position ?? '—'}
</td> </td>
<td
style={{
padding: '0.5rem 0',
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
}}
>
{c.email ?? '—'}
</td>
</tr> </tr>
); );
})} })}
@ -332,81 +298,40 @@ export function CompanyDetailPage() {
{/* Vorgänge */} {/* Vorgänge */}
<div className={styles.card}> <div className={styles.card}>
<h2 className={styles.cardTitle}> <div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Vorgänge ({company._count?.deals ?? deals.length}) Vorgänge ({company._count?.deals ?? deals.length})
</h2> </h2>
</div>
{deals.length === 0 ? ( {deals.length === 0 ? (
<p <p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
Keine Vorgänge vorhanden Keine Vorgänge vorhanden
</p> </p>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table className={styles.compactTable}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}> <tr>
<th <th>Titel</th>
style={{ <th>Stufe</th>
padding: '0.5rem 0', <th style={{ textAlign: 'right' }}>Wert</th>
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Titel
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Stage
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'right',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Wert
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{deals.map((deal) => ( {deals.map((deal) => (
<tr <tr
key={deal.id} key={deal.id}
style={{ data-clickable="true"
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
onClick={() => navigate(`/crm/deals/${deal.id}`)} onClick={() => navigate(`/crm/deals/${deal.id}`)}
> >
<td <td>
style={{ <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
padding: '0.5rem 0', <span style={{ fontWeight: 500 }}>{deal.title}</span>
fontSize: '0.875rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{deal.title}
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
padding: '0 0.375rem', padding: '0 0.25rem',
borderRadius: '9999px', borderRadius: '9999px',
fontSize: '0.6875rem', fontSize: '0.625rem',
fontWeight: 500, fontWeight: 500,
background: STATUS_COLORS[deal.status].bg, background: STATUS_COLORS[deal.status].bg,
color: STATUS_COLORS[deal.status].color, color: STATUS_COLORS[deal.status].color,
@ -416,20 +341,19 @@ export function CompanyDetailPage() {
</span> </span>
</div> </div>
</td> </td>
<td style={{ padding: '0.5rem 0' }}> <td style={{ minWidth: 80 }}>
{deal.stage && ( {deal.stage && (
<span <span
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: '0.375rem', gap: '0.25rem',
fontSize: '0.8125rem',
}} }}
> >
<span <span
style={{ style={{
width: 8, width: 6,
height: 8, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: deal.stage.color, background: deal.stage.color,
display: 'inline-block', display: 'inline-block',
@ -439,14 +363,7 @@ export function CompanyDetailPage() {
</span> </span>
)} )}
</td> </td>
<td <td style={{ textAlign: 'right', fontWeight: 500, whiteSpace: 'nowrap' }}>
style={{
padding: '0.5rem 0',
textAlign: 'right',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{deal.value {deal.value
? currencyFormatter.format(parseFloat(deal.value)) ? currencyFormatter.format(parseFloat(deal.value))
: '—'} : '—'}
@ -458,6 +375,15 @@ export function CompanyDetailPage() {
)} )}
</div> </div>
{/* Beziehungen */}
<CompanyRelationshipsCard companyId={company.id} />
{/* Verträge (Platzhalter) */}
<ContractsCard
companyId={company.id}
contractCount={company._count?.contracts ?? 0}
/>
{/* Lexware Office */} {/* Lexware Office */}
<LexwareSection <LexwareSection
entityType="company" entityType="company"

View file

@ -1,6 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { useCreateCompany, useUpdateCompany } from '../hooks'; import {
useCreateCompany,
useUpdateCompany,
useIndustries,
useAccountTypes,
useTenantUsers,
} from '../hooks';
import type { Company } from '../types'; import type { Company } from '../types';
interface CompanyFormModalProps { interface CompanyFormModalProps {
@ -30,6 +36,11 @@ const inputStyle: React.CSSProperties = {
color: 'var(--color-text)', color: 'var(--color-text)',
}; };
const selectStyle: React.CSSProperties = {
...inputStyle,
cursor: 'pointer',
};
const rowStyle: React.CSSProperties = { const rowStyle: React.CSSProperties = {
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
@ -47,9 +58,20 @@ export function CompanyFormModal({
const updateMutation = useUpdateCompany(); const updateMutation = useUpdateCompany();
const mutation = isEditMode ? updateMutation : createMutation; const mutation = isEditMode ? updateMutation : createMutation;
// Dropdown data
const { data: industriesData } = useIndustries();
const { data: accountTypesData } = useAccountTypes();
const { data: usersData } = useTenantUsers();
const industries = industriesData?.data ?? [];
const accountTypes = accountTypesData?.data ?? [];
const tenantUsers = usersData?.data ?? [];
const [error, setError] = useState(''); const [error, setError] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [industry, setIndustry] = useState(''); const [industryId, setIndustryId] = useState('');
const [accountTypeId, setAccountTypeId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [website, setWebsite] = useState(''); const [website, setWebsite] = useState('');
@ -65,7 +87,9 @@ export function CompanyFormModal({
setError(''); setError('');
if (company) { if (company) {
setName(company.name); setName(company.name);
setIndustry(company.industry ?? ''); setIndustryId(company.industryId ?? '');
setAccountTypeId(company.accountTypeId ?? '');
setOwnerId(company.ownerId ?? '');
setEmail(company.email ?? ''); setEmail(company.email ?? '');
setPhone(company.phone ?? ''); setPhone(company.phone ?? '');
setWebsite(company.website ?? ''); setWebsite(company.website ?? '');
@ -77,7 +101,9 @@ export function CompanyFormModal({
setTagsInput(company.tags?.join(', ') ?? ''); setTagsInput(company.tags?.join(', ') ?? '');
} else { } else {
setName(''); setName('');
setIndustry(''); setIndustryId('');
setAccountTypeId('');
setOwnerId('');
setEmail(''); setEmail('');
setPhone(''); setPhone('');
setWebsite(''); setWebsite('');
@ -105,9 +131,19 @@ export function CompanyFormModal({
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
// Resolve ownerName from selected ownerId
const selectedUser = ownerId
? tenantUsers.find((u) => u.id === ownerId)
: null;
const ownerName = selectedUser
? [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' ') || selectedUser.email
: undefined;
const payload = { const payload = {
name: name.trim(), name: name.trim(),
...(industry ? { industry } : {}), ...(industryId ? { industryId } : {}),
...(accountTypeId ? { accountTypeId } : {}),
...(ownerId ? { ownerId, ownerName } : {}),
...(email ? { email } : {}), ...(email ? { email } : {}),
...(phone ? { phone } : {}), ...(phone ? { phone } : {}),
...(website ? { website } : {}), ...(website ? { website } : {}),
@ -183,12 +219,55 @@ export function CompanyFormModal({
</div> </div>
<div> <div>
<label style={labelStyle}>Branche</label> <label style={labelStyle}>Branche</label>
<input <select
style={inputStyle} style={selectStyle}
value={industry} value={industryId}
onChange={(e) => setIndustry(e.target.value)} onChange={(e) => setIndustryId(e.target.value)}
placeholder="z.B. Enterprise Software" >
/> <option value=""> Keine </option>
{industries.map((ind) => (
<option key={ind.id} value={ind.id}>
{ind.name}
</option>
))}
</select>
</div>
</div>
{/* Kontotyp + Zuständigkeit */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Kontotyp</label>
<select
style={selectStyle}
value={accountTypeId}
onChange={(e) => setAccountTypeId(e.target.value)}
>
<option value=""> Keine </option>
{accountTypes.map((at) => (
<option key={at.id} value={at.id}>
{at.name}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Zuständigkeit</label>
<select
style={selectStyle}
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
>
<option value=""> Kein Owner </option>
{tenantUsers.map((u) => {
const label = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
</div> </div>
</div> </div>

View file

@ -0,0 +1,311 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/Modal';
import {
useCompanyRelationships,
useCreateCompanyRelationship,
useDeleteCompanyRelationship,
useRelationshipTypes,
useCompanies,
} from '../hooks';
import type { CompanyRelationship } from '../types';
import styles from './CompanyDetailPage.module.css';
// ============================================================
// Props
// ============================================================
interface CompanyRelationshipsCardProps {
companyId: string;
}
// ============================================================
// Component
// ============================================================
export function CompanyRelationshipsCard({ companyId }: CompanyRelationshipsCardProps) {
const navigate = useNavigate();
const { data, isLoading } = useCompanyRelationships(companyId);
const deleteMut = useDeleteCompanyRelationship();
const [isAddOpen, setAddOpen] = useState(false);
const relationships: CompanyRelationship[] = data?.data ?? [];
const handleDelete = (relId: string) => {
if (window.confirm('Beziehung wirklich entfernen?')) {
deleteMut.mutate({ companyId, relationshipId: relId });
}
};
return (
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Beziehungen ({relationships.length})
</h2>
<button className={styles.smallAddBtn} onClick={() => setAddOpen(true)}>
+ Neu
</button>
</div>
{isLoading ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Laden...
</p>
) : relationships.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Beziehungen vorhanden
</p>
) : (
<div>
{relationships.map((rel) => (
<div key={rel.id} className={styles.relationItem}>
<div className={styles.relationInfo}>
<span
className={styles.relationName}
onClick={() =>
navigate(`/crm/companies/${rel.relatedCompany.id}`)
}
>
{rel.relatedCompany.name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<span className={styles.relationTypeBadge}>
{rel.relationshipType.name}
</span>
{rel.direction === 'incoming' && (
<span
style={{
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
}}
>
(eingehend)
</span>
)}
</div>
</div>
<button
className={styles.relationDeleteBtn}
onClick={() => handleDelete(rel.id)}
title="Entfernen"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Add Modal */}
<AddRelationshipModal
isOpen={isAddOpen}
onClose={() => setAddOpen(false)}
companyId={companyId}
/>
</div>
);
}
// ============================================================
// Add Relationship Modal
// ============================================================
interface AddRelationshipModalProps {
isOpen: boolean;
onClose: () => void;
companyId: string;
}
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const selectStyle: 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)',
cursor: 'pointer',
};
function AddRelationshipModal({ isOpen, onClose, companyId }: AddRelationshipModalProps) {
const createMut = useCreateCompanyRelationship();
const { data: typesData } = useRelationshipTypes();
const { data: companiesData } = useCompanies({ page: 1, pageSize: 200 });
const types = typesData?.data ?? [];
const companies = (companiesData?.data ?? []).filter((c) => c.id !== companyId);
const [relatedCompanyId, setRelatedCompanyId] = useState('');
const [relationshipTypeId, setRelationshipTypeId] = useState('');
const [notes, setNotes] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!relatedCompanyId || !relationshipTypeId) {
setError('Bitte wähle ein Unternehmen und einen Beziehungstyp.');
return;
}
createMut.mutate(
{
companyId,
data: {
relatedCompanyId,
relationshipTypeId,
...(notes.trim() ? { notes: notes.trim() } : {}),
},
},
{
onSuccess: () => {
setRelatedCompanyId('');
setRelationshipTypeId('');
setNotes('');
onClose();
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Erstellen';
setError(msg);
},
},
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Beziehung hinzufügen"
maxWidth="460px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Unternehmen *</label>
<select
style={selectStyle}
value={relatedCompanyId}
onChange={(e) => setRelatedCompanyId(e.target.value)}
required
>
<option value=""> Unternehmen wählen </option>
{companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Beziehungstyp *</label>
<select
style={selectStyle}
value={relationshipTypeId}
onChange={(e) => setRelationshipTypeId(e.target.value)}
required
>
<option value=""> Typ wählen </option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{
...selectStyle,
cursor: 'text',
minHeight: 60,
resize: 'vertical',
}}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Notizen..."
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button
type="button"
onClick={onClose}
disabled={createMut.isPending}
style={{
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={createMut.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: createMut.isPending ? 'wait' : 'pointer',
opacity: createMut.isPending ? 0.7 : 1,
}}
>
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,17 @@
import styles from './CompanyDetailPage.module.css';
interface ContractsCardProps {
companyId: string;
contractCount?: number;
}
export function ContractsCard({ contractCount = 0 }: ContractsCardProps) {
return (
<div className={`${styles.card} ${styles.placeholderCard}`}>
<h2 className={styles.cardTitle}>
Verträge{contractCount > 0 ? ` (${contractCount})` : ''}
</h2>
<p>Modul in Entwicklung bald verfügbar.</p>
</div>
);
}

View file

@ -213,8 +213,8 @@ export function DealsPage() {
<th style={thStyle}>Kontakt</th> <th style={thStyle}>Kontakt</th>
<th style={thStyle}>Unternehmen</th> <th style={thStyle}>Unternehmen</th>
<th style={thStyle}>Pipeline</th> <th style={thStyle}>Pipeline</th>
<th style={thStyle}>Stage</th> <th style={{ ...thStyle, minWidth: 120 }}>Stage</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Wert</th> <th style={{ ...thStyle, textAlign: 'right', minWidth: 100 }}>Wert</th>
<th style={thStyle}>Status</th> <th style={thStyle}>Status</th>
<th style={thStyle}>Aktionen</th> <th style={thStyle}>Aktionen</th>
</tr> </tr>

View file

@ -9,6 +9,11 @@ import {
pipelinesApi, pipelinesApi,
activitiesApi, activitiesApi,
companiesApi, companiesApi,
industriesApi,
accountTypesApi,
relationshipTypesApi,
companyRelationshipsApi,
usersApi,
lexwareContactsApi, lexwareContactsApi,
lexwareVouchersApi, lexwareVouchersApi,
} from './api'; } from './api';
@ -29,6 +34,13 @@ import type {
CompaniesQueryParams, CompaniesQueryParams,
CreateCompanyPayload, CreateCompanyPayload,
UpdateCompanyPayload, UpdateCompanyPayload,
CreateIndustryPayload,
UpdateIndustryPayload,
CreateAccountTypePayload,
UpdateAccountTypePayload,
CreateRelationshipTypePayload,
UpdateRelationshipTypePayload,
CreateCompanyRelationshipPayload,
LexwareContactSearchParams, LexwareContactSearchParams,
LexwareVouchersQueryParams, LexwareVouchersQueryParams,
} from './types'; } from './types';
@ -65,6 +77,27 @@ export const crmKeys = {
['crm', 'companies', 'list', params] as const, ['crm', 'companies', 'list', params] as const,
detail: (id: string) => ['crm', 'companies', 'detail', id] as const, detail: (id: string) => ['crm', 'companies', 'detail', id] as const,
}, },
industries: {
all: ['crm', 'industries'] as const,
list: () => ['crm', 'industries', 'list'] as const,
},
accountTypes: {
all: ['crm', 'accountTypes'] as const,
list: () => ['crm', 'accountTypes', 'list'] as const,
},
relationshipTypes: {
all: ['crm', 'relationshipTypes'] as const,
list: () => ['crm', 'relationshipTypes', 'list'] as const,
},
companyRelationships: {
all: ['crm', 'companyRelationships'] as const,
list: (companyId: string) =>
['crm', 'companyRelationships', 'list', companyId] as const,
},
users: {
all: ['crm', 'users'] as const,
list: () => ['crm', 'users', 'list'] as const,
},
lexware: { lexware: {
all: ['crm', 'lexware'] as const, all: ['crm', 'lexware'] as const,
contactSearch: (params: LexwareContactSearchParams) => contactSearch: (params: LexwareContactSearchParams) =>
@ -392,6 +425,226 @@ export function useDeleteCompany() {
}); });
} }
// ============================================================
// Industries
// ============================================================
export function useIndustries() {
return useQuery({
queryKey: crmKeys.industries.list(),
queryFn: () => industriesApi.list(),
staleTime: 10 * 60 * 1000,
});
}
export function useCreateIndustry() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateIndustryPayload) => industriesApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.industries.all });
},
});
}
export function useUpdateIndustry() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateIndustryPayload }) =>
industriesApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.industries.all });
},
});
}
export function useDeleteIndustry() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => industriesApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.industries.all });
},
});
}
// ============================================================
// Account Types
// ============================================================
export function useAccountTypes() {
return useQuery({
queryKey: crmKeys.accountTypes.list(),
queryFn: () => accountTypesApi.list(),
staleTime: 10 * 60 * 1000,
});
}
export function useCreateAccountType() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateAccountTypePayload) =>
accountTypesApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.accountTypes.all });
},
});
}
export function useUpdateAccountType() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: UpdateAccountTypePayload;
}) => accountTypesApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.accountTypes.all });
},
});
}
export function useDeleteAccountType() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => accountTypesApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.accountTypes.all });
},
});
}
// ============================================================
// Relationship Types
// ============================================================
export function useRelationshipTypes() {
return useQuery({
queryKey: crmKeys.relationshipTypes.list(),
queryFn: () => relationshipTypesApi.list(),
staleTime: 10 * 60 * 1000,
});
}
export function useCreateRelationshipType() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateRelationshipTypePayload) =>
relationshipTypesApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.relationshipTypes.all });
},
});
}
export function useUpdateRelationshipType() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: UpdateRelationshipTypePayload;
}) => relationshipTypesApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.relationshipTypes.all });
},
});
}
export function useDeleteRelationshipType() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => relationshipTypesApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.relationshipTypes.all });
},
});
}
// ============================================================
// Company Relationships
// ============================================================
export function useCompanyRelationships(companyId: string) {
return useQuery({
queryKey: crmKeys.companyRelationships.list(companyId),
queryFn: () => companyRelationshipsApi.list(companyId),
enabled: !!companyId,
});
}
export function useCreateCompanyRelationship() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
companyId,
data,
}: {
companyId: string;
data: CreateCompanyRelationshipPayload;
}) => companyRelationshipsApi.create(companyId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companyRelationships.all });
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
},
});
}
export function useDeleteCompanyRelationship() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
companyId,
relationshipId,
}: {
companyId: string;
relationshipId: string;
}) => companyRelationshipsApi.delete(companyId, relationshipId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companyRelationships.all });
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
},
});
}
// ============================================================
// Tenant Users
// ============================================================
export function useTenantUsers() {
return useQuery({
queryKey: crmKeys.users.list(),
queryFn: () => usersApi.list(),
staleTime: 10 * 60 * 1000,
});
}
// ============================================================
// Company Activities (Aggregated Feed)
// ============================================================
export function useCompanyActivities(
companyId: string,
params: Omit<ActivitiesQueryParams, 'companyId' | 'includeContacts'> = {},
) {
const fullParams: ActivitiesQueryParams = {
...params,
companyId,
includeContacts: true,
pageSize: params.pageSize ?? 50,
};
return useQuery({
queryKey: crmKeys.activities.list(fullParams),
queryFn: () => activitiesApi.list(fullParams),
enabled: !!companyId,
});
}
// ============================================================ // ============================================================
// Lexware Office — Contacts // Lexware Office — Contacts
// ============================================================ // ============================================================

View file

@ -7,6 +7,113 @@
export type ContactType = 'PERSON' | 'ORGANIZATION'; export type ContactType = 'PERSON' | 'ORGANIZATION';
export type DealStatus = 'OPEN' | 'WON' | 'LOST'; export type DealStatus = 'OPEN' | 'WON' | 'LOST';
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK'; export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK';
export type ContractStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
// --- Admin-konfigurierbare Entitaeten ---
export interface Industry {
id: string;
tenantId: string;
name: string;
color: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
_count?: { companies: number };
}
export interface AccountType {
id: string;
tenantId: string;
name: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
_count?: { companies: number };
}
export interface RelationshipType {
id: string;
tenantId: string;
name: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
_count?: { relationships: number };
}
export interface CompanyRelationship {
id: string;
tenantId: string;
companyId: string;
relatedCompanyId: string;
relationshipTypeId: string;
notes: string | null;
createdBy: string;
createdAt: string;
relatedCompany: {
id: string;
name: string;
industry: string | null;
city: string | null;
};
relationshipType: {
id: string;
name: string;
};
direction?: 'outgoing' | 'incoming';
}
export interface Contract {
id: string;
tenantId: string;
companyId: string;
title: string;
status: ContractStatus;
startDate: string | null;
endDate: string | null;
value: string | null;
currency: string;
notes: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface TenantUser {
id: string;
firstName: string | null;
lastName: string | null;
email: string;
}
export interface CreateIndustryPayload {
name: string;
color?: string;
sortOrder?: number;
}
export type UpdateIndustryPayload = Partial<CreateIndustryPayload>;
export interface CreateAccountTypePayload {
name: string;
sortOrder?: number;
}
export type UpdateAccountTypePayload = Partial<CreateAccountTypePayload>;
export interface CreateRelationshipTypePayload {
name: string;
sortOrder?: number;
}
export type UpdateRelationshipTypePayload = Partial<CreateRelationshipTypePayload>;
export interface CreateCompanyRelationshipPayload {
relatedCompanyId: string;
relationshipTypeId: string;
notes?: string;
}
// --- Contact --- // --- Contact ---
@ -76,7 +183,8 @@ export type UpdateContactPayload = Partial<CreateContactPayload>;
export interface Activity { export interface Activity {
id: string; id: string;
tenantId: string; tenantId: string;
contactId: string; contactId: string | null;
companyId: string | null;
type: ActivityType; type: ActivityType;
subject: string; subject: string;
description: string | null; description: string | null;
@ -91,11 +199,16 @@ export interface Activity {
firstName: string | null; firstName: string | null;
lastName: string | null; lastName: string | null;
companyName: string | null; companyName: string | null;
}; } | null;
company?: {
id: string;
name: string;
} | null;
} }
export interface CreateActivityPayload { export interface CreateActivityPayload {
contactId: string; contactId?: string;
companyId?: string;
type: ActivityType; type: ActivityType;
subject: string; subject: string;
description?: string; description?: string;
@ -103,7 +216,7 @@ export interface CreateActivityPayload {
completedAt?: string; completedAt?: string;
} }
export type UpdateActivityPayload = Partial<Omit<CreateActivityPayload, 'contactId'>>; export type UpdateActivityPayload = Partial<Omit<CreateActivityPayload, 'contactId' | 'companyId'>>;
// --- Pipeline + Stage --- // --- Pipeline + Stage ---
@ -208,6 +321,10 @@ export interface Company {
tenantId: string; tenantId: string;
name: string; name: string;
industry: string | null; industry: string | null;
industryId: string | null;
accountTypeId: string | null;
ownerId: string | null;
ownerName: string | null;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
website: string | null; website: string | null;
@ -226,7 +343,9 @@ export interface Company {
lexwareContactId: string | null; lexwareContactId: string | null;
lexwareContactVersion: number | null; lexwareContactVersion: number | null;
lexwareSyncedAt: string | null; lexwareSyncedAt: string | null;
_count?: { contacts: number; deals: number; lexwareVouchers?: number }; industryRef?: Industry | null;
accountType?: AccountType | null;
_count?: { contacts: number; deals: number; lexwareVouchers?: number; contracts?: number };
contacts?: { contacts?: {
id: string; id: string;
firstName: string | null; firstName: string | null;
@ -237,11 +356,18 @@ export interface Company {
isActive: boolean; isActive: boolean;
}[]; }[];
deals?: Deal[]; deals?: Deal[];
relationships?: CompanyRelationship[];
relatedRelationships?: CompanyRelationship[];
contracts?: Contract[];
} }
export interface CreateCompanyPayload { export interface CreateCompanyPayload {
name: string; name: string;
industry?: string; industry?: string;
industryId?: string;
accountTypeId?: string;
ownerId?: string;
ownerName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
website?: string; website?: string;
@ -307,6 +433,8 @@ export interface ActivitiesQueryParams {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
contactId?: string; contactId?: string;
companyId?: string;
includeContacts?: boolean;
type?: ActivityType; type?: ActivityType;
sort?: string; sort?: string;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';