mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 04:56:40 +02:00
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:
parent
4e5c26cadd
commit
0ed1e77844
41 changed files with 3295 additions and 235 deletions
|
|
@ -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,29 +53,40 @@ 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) LexwareVoucher — companyId (optional, SetNull)
|
Company (1) --< (n) Activity — companyId (optional, SetNull)
|
||||||
Contact (1) --< (n) Activity — contactId (Cascade)
|
Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
|
||||||
Contact (1) --< (n) Deal — contactId (optional, SetNull)
|
Company (n) >--< (n) Company — via CompanyRelationship (bidirektional)
|
||||||
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
|
Company (1) --< (n) Contract — companyId (Cascade)
|
||||||
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
|
Company (n) --> (1) Industry — industryId (optional, SetNull)
|
||||||
Pipeline (1) --< (n) Deal — pipelineId (Cascade)
|
Company (n) --> (1) AccountType — accountTypeId (optional, SetNull)
|
||||||
PipelineStage (1) --< (n) Deal — stageId
|
Contact (1) --< (n) Activity — contactId (optional, SetNull)
|
||||||
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
Contact (1) --< (n) Deal — contactId (optional, SetNull)
|
||||||
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
|
||||||
|
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
|
||||||
|
Pipeline (1) --< (n) Deal — pipelineId (Cascade)
|
||||||
|
PipelineStage (1) --< (n) Deal — stageId
|
||||||
|
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
||||||
|
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
||||||
|
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-Endpunkte
|
### 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
contacts Contact[]
|
industryRef Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull)
|
||||||
deals Deal[]
|
accountType AccountType? @relation(fields: [accountTypeId], references: [id], onDelete: SetNull)
|
||||||
lexwareVouchers LexwareVoucher[]
|
contacts Contact[]
|
||||||
|
deals Deal[]
|
||||||
|
activities Activity[]
|
||||||
|
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)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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" != '';
|
||||||
46
packages/crm-service/prisma/seed-config-data.sql
Normal file
46
packages/crm-service/prisma/seed-config-data.sql
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
const contact = await this.prisma.contact.findFirst({
|
if (!dto.contactId && !dto.companyId) {
|
||||||
where: { id: dto.contactId, tenantId },
|
throw new BadRequestException(
|
||||||
});
|
'Mindestens contactId oder companyId muss angegeben werden',
|
||||||
if (!contact) {
|
);
|
||||||
throw new NotFoundException('Kontakt nicht gefunden');
|
}
|
||||||
|
|
||||||
|
// Kontakt validieren (falls gesetzt)
|
||||||
|
if (dto.contactId) {
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id: dto.contactId, tenantId },
|
||||||
|
});
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unternehmen validieren (falls gesetzt)
|
||||||
|
if (dto.companyId) {
|
||||||
|
const company = await this.prisma.company.findFirst({
|
||||||
|
where: { id: dto.companyId, tenantId },
|
||||||
|
});
|
||||||
|
if (!company) {
|
||||||
|
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.activity.create({
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
98
packages/crm-service/src/industries/industries.controller.ts
Normal file
98
packages/crm-service/src/industries/industries.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/industries/industries.module.ts
Normal file
10
packages/crm-service/src/industries/industries.module.ts
Normal 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 {}
|
||||||
96
packages/crm-service/src/industries/industries.service.ts
Normal file
96
packages/crm-service/src/industries/industries.service.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } : {}),
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
230
packages/frontend/src/crm/companies/ActivityFeed.tsx
Normal file
230
packages/frontend/src/crm/companies/ActivityFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
Kontakte ({company._count?.contacts ?? contacts.length})
|
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
|
||||||
</h2>
|
Kontakte ({company._count?.contacts ?? contacts.length})
|
||||||
|
</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}>
|
||||||
Vorgänge ({company._count?.deals ?? deals.length})
|
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
|
||||||
</h2>
|
Vorgänge ({company._count?.deals ?? deals.length})
|
||||||
|
</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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
311
packages/frontend/src/crm/companies/CompanyRelationshipsCard.tsx
Normal file
311
packages/frontend/src/crm/companies/CompanyRelationshipsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
packages/frontend/src/crm/companies/ContractsCard.tsx
Normal file
17
packages/frontend/src/crm/companies/ContractsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue