mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26: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
|
||||
|
||||
## Stand: 2026-03-10
|
||||
## Stand: 2026-03-11
|
||||
|
||||
### Was wurde erstellt
|
||||
|
||||
|
|
@ -26,13 +26,17 @@ packages/crm-service/
|
|||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
||||
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update)
|
||||
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push, industryId, accountTypeId, ownerId)
|
||||
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK; contactId+companyId optional)
|
||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers
|
||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
||||
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
||||
company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional)
|
||||
health/ — Health-Check (DB, Redis, Lexware)
|
||||
lexware/ — Lexware Office Integration (NEU)
|
||||
lexware/ — Lexware Office Integration
|
||||
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
|
||||
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
|
||||
lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync
|
||||
|
|
@ -49,22 +53,32 @@ packages/crm-service/
|
|||
|
||||
### Datenbank-Modelle (app_crm Schema)
|
||||
|
||||
- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt)
|
||||
- **Company** — Unternehmen mit industryId, accountTypeId, ownerId, Lexware-Verknuepfung
|
||||
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
|
||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten
|
||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1)
|
||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
|
||||
- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften)
|
||||
- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
||||
- **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant)
|
||||
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
||||
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
||||
- **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen
|
||||
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter)
|
||||
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
||||
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
||||
|
||||
### Entity-Beziehungen
|
||||
|
||||
```
|
||||
Company (1) --< (n) Contact — companyId (optional, SetNull)
|
||||
Company (1) --< (n) Deal — companyId (optional, SetNull)
|
||||
Company (1) --< (n) Activity — companyId (optional, SetNull)
|
||||
Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
|
||||
Contact (1) --< (n) Activity — contactId (Cascade)
|
||||
Company (n) >--< (n) Company — via CompanyRelationship (bidirektional)
|
||||
Company (1) --< (n) Contract — companyId (Cascade)
|
||||
Company (n) --> (1) Industry — industryId (optional, SetNull)
|
||||
Company (n) --> (1) AccountType — accountTypeId (optional, SetNull)
|
||||
Contact (1) --< (n) Activity — contactId (optional, SetNull)
|
||||
Contact (1) --< (n) Deal — contactId (optional, SetNull)
|
||||
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
|
||||
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
|
||||
|
|
@ -72,6 +86,7 @@ Pipeline (1) --< (n) Deal — pipelineId (Cascade)
|
|||
PipelineStage (1) --< (n) Deal — stageId
|
||||
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
||||
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
||||
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||
```
|
||||
|
||||
### API-Endpunkte
|
||||
|
|
@ -82,8 +97,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
|||
| GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete |
|
||||
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete |
|
||||
| GET/POST | /api/v1/crm/activities | Liste / Erstellen |
|
||||
| GET/POST | /api/v1/crm/activities | Liste / Erstellen (companyId+includeContacts fuer aggregierten Feed) |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete |
|
||||
| GET/POST | /api/v1/crm/industries | Branchen verwalten |
|
||||
| PATCH/DELETE | /api/v1/crm/industries/:id | Branche bearbeiten / loeschen |
|
||||
| GET/POST | /api/v1/crm/account-types | Kontotypen verwalten |
|
||||
| PATCH/DELETE | /api/v1/crm/account-types/:id | Kontotyp bearbeiten / loeschen |
|
||||
| GET/POST | /api/v1/crm/relationship-types | Beziehungstypen verwalten |
|
||||
| PATCH/DELETE | /api/v1/crm/relationship-types/:id | Beziehungstyp bearbeiten / loeschen |
|
||||
| GET/POST | /api/v1/crm/companies/:id/relationships | Beziehungen verwalten |
|
||||
| DELETE | /api/v1/crm/companies/:id/relationships/:relId | Beziehung loeschen |
|
||||
| GET | /api/v1/crm/users | Tenant-User fuer Owner-Dropdown |
|
||||
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
|
||||
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||
|
|
@ -141,14 +165,17 @@ LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
|||
- Prisma Migrationen:
|
||||
- `20260310163211_init` — Initiales Schema
|
||||
- `20260310183117_add_companies` — Company-Entity
|
||||
- `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND)
|
||||
- `20260310_add_lexware_integration` — Lexware Office Integration
|
||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul (Industry, AccountType, RelationshipType, CompanyRelationship, Contract, Activity companyId)
|
||||
|
||||
### Naechste Schritte
|
||||
|
||||
1. Migration `20260310_add_lexware_integration` auf Server anwenden
|
||||
2. `LEXWARE_API_KEY` in `.env` auf Server setzen
|
||||
1. Migration `20260311_add_company_detail_overhaul` auf Server anwenden
|
||||
2. Seed-Data (Industries, AccountTypes, RelationshipTypes) ausfuehren
|
||||
3. Container neu bauen und deployen
|
||||
4. Lexware-Endpunkte auf Server testen
|
||||
5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten
|
||||
6. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
|
||||
7. Kanban-Board fuer Vorgaenge
|
||||
4. Frontend testen: CompanyDetailPage 3-Spalten Layout
|
||||
5. CRM-Einstellungen: Branchen/Kontotypen/Beziehungstypen verwalten
|
||||
6. CompanyFormModal: Dropdowns testen
|
||||
7. Activity Feed: Aggregierten Feed testen
|
||||
8. Kanban-Board fuer Vorgaenge
|
||||
9. Vertraege-UI implementieren (DB-Modell bereits vorhanden)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,64 @@ datasource db {
|
|||
schemas = ["app_crm"]
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Industry - Branche (admin-konfigurierbar pro Tenant)
|
||||
// --------------------------------------------------------
|
||||
model Industry {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
color String @default("#6B7280") @db.VarChar(7)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
companies Company[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@map("industries")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// AccountType - Kontotyp (admin-konfigurierbar pro Tenant)
|
||||
// --------------------------------------------------------
|
||||
model AccountType {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
companies Company[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@map("account_types")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// RelationshipType - Beziehungstyp (admin-konfigurierbar)
|
||||
// --------------------------------------------------------
|
||||
model RelationshipType {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
relationships CompanyRelationship[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@map("relationship_types")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Company - Unternehmen (uebergeordnete Entity)
|
||||
// --------------------------------------------------------
|
||||
|
|
@ -27,6 +85,12 @@ model Company {
|
|||
name String @db.VarChar(200)
|
||||
industry String? @db.VarChar(100)
|
||||
|
||||
// Admin-konfigurierbare Felder
|
||||
industryId String? @map("industry_id") @db.Uuid
|
||||
accountTypeId String? @map("account_type_id") @db.Uuid
|
||||
ownerId String? @map("owner_id") @db.Uuid
|
||||
ownerName String? @map("owner_name") @db.VarChar(200)
|
||||
|
||||
// Kontaktdaten
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
|
|
@ -58,14 +122,22 @@ model Company {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
industryRef Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull)
|
||||
accountType AccountType? @relation(fields: [accountTypeId], references: [id], onDelete: SetNull)
|
||||
contacts Contact[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
lexwareVouchers LexwareVoucher[]
|
||||
relationships CompanyRelationship[] @relation("companyRelationships")
|
||||
relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships")
|
||||
contracts Contract[]
|
||||
|
||||
@@unique([tenantId, lexwareContactId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, name])
|
||||
@@index([tenantId, industry])
|
||||
@@index([tenantId, industryId])
|
||||
@@index([tenantId, accountTypeId])
|
||||
@@index([tenantId, isActive])
|
||||
@@map("companies")
|
||||
@@schema("app_crm")
|
||||
|
|
@ -149,7 +221,8 @@ enum ContactType {
|
|||
model Activity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
contactId String @map("contact_id") @db.Uuid
|
||||
contactId String? @map("contact_id") @db.Uuid
|
||||
companyId String? @map("company_id") @db.Uuid
|
||||
type ActivityType
|
||||
subject String @db.VarChar(500)
|
||||
description String? @db.Text
|
||||
|
|
@ -166,10 +239,12 @@ model Activity {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, contactId])
|
||||
@@index([tenantId, companyId])
|
||||
@@index([tenantId, type])
|
||||
@@index([tenantId, scheduledAt])
|
||||
@@map("activities")
|
||||
|
|
@ -186,6 +261,67 @@ enum ActivityType {
|
|||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// CompanyRelationship - Beziehungen zwischen Unternehmen (N:M)
|
||||
// --------------------------------------------------------
|
||||
model CompanyRelationship {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
companyId String @map("company_id") @db.Uuid
|
||||
relatedCompanyId String @map("related_company_id") @db.Uuid
|
||||
relationshipTypeId String @map("relationship_type_id") @db.Uuid
|
||||
notes String? @db.Text
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relationen
|
||||
company Company @relation("companyRelationships", fields: [companyId], references: [id], onDelete: Cascade)
|
||||
relatedCompany Company @relation("relatedCompanyRelationships", fields: [relatedCompanyId], references: [id], onDelete: Cascade)
|
||||
relationshipType RelationshipType @relation(fields: [relationshipTypeId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([companyId, relatedCompanyId, relationshipTypeId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, companyId])
|
||||
@@index([tenantId, relatedCompanyId])
|
||||
@@map("company_relationships")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Contract - Vertraege (Platzhalter fuer zukuenftiges Modul)
|
||||
// --------------------------------------------------------
|
||||
model Contract {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
companyId String @map("company_id") @db.Uuid
|
||||
title String @db.VarChar(255)
|
||||
status ContractStatus @default(DRAFT)
|
||||
startDate DateTime? @map("start_date")
|
||||
endDate DateTime? @map("end_date")
|
||||
value Decimal? @db.Decimal(15, 2)
|
||||
currency String @default("EUR") @db.VarChar(3)
|
||||
notes String? @db.Text
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId, companyId])
|
||||
@@map("contracts")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum ContractStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Pipeline - Sales-Pipelines (konfigurierbar pro Tenant)
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 { CreateActivityDto } from './dto/create-activity.dto';
|
||||
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||
|
|
@ -10,18 +14,38 @@ export class ActivitiesService {
|
|||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateActivityDto) {
|
||||
// Pruefen ob der Kontakt existiert und zum Tenant gehoert
|
||||
// Mindestens contactId oder companyId muss gesetzt sein
|
||||
if (!dto.contactId && !dto.companyId) {
|
||||
throw new BadRequestException(
|
||||
'Mindestens contactId oder companyId muss angegeben werden',
|
||||
);
|
||||
}
|
||||
|
||||
// Kontakt validieren (falls gesetzt)
|
||||
if (dto.contactId) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: dto.contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// Unternehmen validieren (falls gesetzt)
|
||||
if (dto.companyId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: dto.companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.activity.create({
|
||||
data: {
|
||||
tenantId,
|
||||
contactId: dto.contactId,
|
||||
companyId: dto.companyId,
|
||||
type: dto.type,
|
||||
subject: dto.subject,
|
||||
description: dto.description,
|
||||
|
|
@ -29,7 +53,14 @@ export class ActivitiesService {
|
|||
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
|
||||
createdBy: userId,
|
||||
},
|
||||
include: { contact: true },
|
||||
include: {
|
||||
contact: {
|
||||
select: { id: true, firstName: true, lastName: true, companyName: true },
|
||||
},
|
||||
company: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -39,9 +70,27 @@ export class ActivitiesService {
|
|||
|
||||
const where: Prisma.ActivityWhereInput = { tenantId };
|
||||
|
||||
if (query.contactId) {
|
||||
// Aggregierter Company-Feed: direkte + verknuepfte Kontakt-Aktivitaeten
|
||||
if (query.companyId && query.includeContacts) {
|
||||
const contactIds = await this.prisma.contact
|
||||
.findMany({
|
||||
where: { tenantId, companyId: query.companyId },
|
||||
select: { id: true },
|
||||
})
|
||||
.then((contacts) => contacts.map((c) => c.id));
|
||||
|
||||
where.OR = [
|
||||
{ companyId: query.companyId },
|
||||
...(contactIds.length > 0
|
||||
? [{ contactId: { in: contactIds } }]
|
||||
: []),
|
||||
];
|
||||
} else if (query.companyId) {
|
||||
where.companyId = query.companyId;
|
||||
} else if (query.contactId) {
|
||||
where.contactId = query.contactId;
|
||||
}
|
||||
|
||||
if (query.type) {
|
||||
where.type = query.type;
|
||||
}
|
||||
|
|
@ -57,7 +106,14 @@ export class ActivitiesService {
|
|||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||
include: { contact: { select: { id: true, firstName: true, lastName: true, companyName: true } } },
|
||||
include: {
|
||||
contact: {
|
||||
select: { id: true, firstName: true, lastName: true, companyName: true },
|
||||
},
|
||||
company: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
]);
|
||||
|
|
@ -68,7 +124,10 @@ export class ActivitiesService {
|
|||
async findOne(tenantId: string, id: string) {
|
||||
const activity = await this.prisma.activity.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { contact: true },
|
||||
include: {
|
||||
contact: true,
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
|
|
@ -94,7 +153,14 @@ export class ActivitiesService {
|
|||
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
|
||||
updatedBy: userId,
|
||||
},
|
||||
include: { contact: true },
|
||||
include: {
|
||||
contact: {
|
||||
select: { id: true, firstName: true, lastName: true, companyName: true },
|
||||
},
|
||||
company: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
IsUUID,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
|
|
@ -17,9 +18,17 @@ export enum ActivityType {
|
|||
}
|
||||
|
||||
export class CreateActivityDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Kontakt-ID (optional wenn companyId gesetzt)' })
|
||||
@IsOptional()
|
||||
@ValidateIf((o) => !o.companyId)
|
||||
@IsUUID()
|
||||
contactId!: string;
|
||||
contactId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Unternehmens-ID (optional wenn contactId gesetzt)' })
|
||||
@IsOptional()
|
||||
@ValidateIf((o) => !o.contactId)
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiProperty({ enum: ActivityType })
|
||||
@IsEnum(ActivityType)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEnum, IsUUID, IsBoolean } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import { ActivityType } from './create-activity.dto';
|
||||
|
||||
|
|
@ -9,6 +10,20 @@ export class QueryActivitiesDto extends PaginationDto {
|
|||
@IsUUID()
|
||||
contactId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Unternehmen' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Kontakt-Aktivitaeten inkludieren (nur bei companyId)',
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
includeContacts?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: ActivityType })
|
||||
@IsOptional()
|
||||
@IsEnum(ActivityType)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import { PipelinesModule } from './pipelines/pipelines.module';
|
|||
import { DealsModule } from './deals/deals.module';
|
||||
import { CompaniesModule } from './companies/companies.module';
|
||||
import { LexwareModule } from './lexware/lexware.module';
|
||||
import { IndustriesModule } from './industries/industries.module';
|
||||
import { AccountTypesModule } from './account-types/account-types.module';
|
||||
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
||||
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -33,6 +37,10 @@ import { LexwareModule } from './lexware/lexware.module';
|
|||
DealsModule,
|
||||
CompaniesModule,
|
||||
LexwareModule,
|
||||
IndustriesModule,
|
||||
AccountTypesModule,
|
||||
RelationshipTypesModule,
|
||||
CompanyRelationshipsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export class CompaniesService {
|
|||
createdBy: userId,
|
||||
name: dto.name,
|
||||
industry: dto.industry,
|
||||
industryId: dto.industryId,
|
||||
accountTypeId: dto.accountTypeId,
|
||||
ownerId: dto.ownerId,
|
||||
ownerName: dto.ownerName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
website: dto.website,
|
||||
|
|
@ -35,6 +39,8 @@ export class CompaniesService {
|
|||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
|
|
@ -77,6 +83,8 @@ export class CompaniesService {
|
|||
take: pageSize,
|
||||
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
}),
|
||||
|
|
@ -90,6 +98,8 @@ export class CompaniesService {
|
|||
const company = await this.prisma.company.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
contacts: {
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
|
@ -112,8 +122,34 @@ export class CompaniesService {
|
|||
stage: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
include: {
|
||||
relatedCompany: {
|
||||
select: { id: true, name: true, industry: true, city: true },
|
||||
},
|
||||
relationshipType: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
relatedRelationships: {
|
||||
include: {
|
||||
company: {
|
||||
select: { id: true, name: true, industry: true, city: true },
|
||||
},
|
||||
relationshipType: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
contracts: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
_count: {
|
||||
select: { contacts: true, deals: true, lexwareVouchers: true },
|
||||
select: { contacts: true, deals: true, lexwareVouchers: true, contracts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -140,6 +176,8 @@ export class CompaniesService {
|
|||
updatedBy: userId,
|
||||
},
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IsBoolean,
|
||||
IsEmail,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
|
@ -15,12 +16,33 @@ export class CreateCompanyDto {
|
|||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@ApiPropertyOptional({ maxLength: 100, description: 'Freitext-Branche (Legacy)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Branchen-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
industryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Kontotyp-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
accountTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Zustaendiger User (Owner-ID)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
ownerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Name des zustaendigen Users' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
ownerName?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IsBoolean,
|
||||
IsEmail,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
|
@ -22,6 +23,27 @@ export class UpdateCompanyDto {
|
|||
@MaxLength(100)
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Branchen-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
industryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Kontotyp-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
accountTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Zustaendiger User (Owner-ID)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
ownerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Name des zustaendigen Users' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
ownerName?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contactId: string;
|
||||
contactId?: string;
|
||||
companyId?: string;
|
||||
activity?: Activity | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
|
@ -51,6 +52,7 @@ export function ActivityFormModal({
|
|||
isOpen,
|
||||
onClose,
|
||||
contactId,
|
||||
companyId,
|
||||
activity,
|
||||
onSuccess,
|
||||
}: ActivityFormModalProps) {
|
||||
|
|
@ -127,7 +129,8 @@ export function ActivityFormModal({
|
|||
} else {
|
||||
createMutation.mutate(
|
||||
{
|
||||
contactId,
|
||||
...(contactId ? { contactId } : {}),
|
||||
...(companyId ? { companyId } : {}),
|
||||
type,
|
||||
subject: subject.trim(),
|
||||
...(description ? { description } : {}),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ import type {
|
|||
CreateCompanyPayload,
|
||||
UpdateCompanyPayload,
|
||||
CompaniesQueryParams,
|
||||
Industry,
|
||||
CreateIndustryPayload,
|
||||
UpdateIndustryPayload,
|
||||
AccountType,
|
||||
CreateAccountTypePayload,
|
||||
UpdateAccountTypePayload,
|
||||
RelationshipType,
|
||||
CreateRelationshipTypePayload,
|
||||
UpdateRelationshipTypePayload,
|
||||
CompanyRelationship,
|
||||
CreateCompanyRelationshipPayload,
|
||||
TenantUser,
|
||||
LexwareContact,
|
||||
LexwareContactSearchParams,
|
||||
LexwareVoucher,
|
||||
|
|
@ -205,6 +217,126 @@ export const companiesApi = {
|
|||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Industries ---
|
||||
|
||||
export const industriesApi = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ success: boolean; data: Industry[]; meta: { timestamp: string } }>(
|
||||
'/crm/industries',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
create: (data: CreateIndustryPayload) =>
|
||||
api
|
||||
.post<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 ---
|
||||
|
||||
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 {
|
||||
|
|
@ -39,19 +39,45 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-columns: 300px 1fr 360px;
|
||||
gap: 1.5rem;
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Cards
|
||||
============================================================ */
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
|
|
@ -67,21 +93,38 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tags {
|
||||
|
|
@ -96,7 +139,7 @@
|
|||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +150,339 @@
|
|||
}
|
||||
|
||||
.notesText {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
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 { useCompany, useDeleteCompany } from '../hooks';
|
||||
import { CompanyFormModal } from './CompanyFormModal';
|
||||
import { ActivityFeed } from './ActivityFeed';
|
||||
import { CompanyRelationshipsCard } from './CompanyRelationshipsCard';
|
||||
import { ContractsCard } from './ContractsCard';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { LexwareSection } from '../lexware/LexwareSection';
|
||||
import type { DealStatus } from '../types';
|
||||
import styles from './CompanyDetailPage.module.css';
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
|
||||
OPEN: { bg: '#dbeafe', color: '#1e40af' },
|
||||
WON: { bg: '#d1fae5', color: '#065f46' },
|
||||
|
|
@ -32,6 +39,10 @@ function formatDate(iso: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Page Component
|
||||
// ============================================================
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -53,6 +64,10 @@ export function CompanyDetailPage() {
|
|||
const contacts = company.contacts ?? [];
|
||||
const deals = company.deals ?? [];
|
||||
|
||||
// Industry badge color
|
||||
const industryColor = company.industryRef?.color ?? '#6366f1';
|
||||
const industryName = company.industryRef?.name ?? company.industry;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Zurück */}
|
||||
|
|
@ -74,19 +89,12 @@ export function CompanyDetailPage() {
|
|||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<h1 className={styles.name}>{company.name}</h1>
|
||||
{company.industry && (
|
||||
{industryName && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
background: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
}}
|
||||
className={styles.industryBadge}
|
||||
style={{ backgroundColor: industryColor }}
|
||||
>
|
||||
{company.industry}
|
||||
{industryName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
|
|
@ -134,13 +142,25 @@ export function CompanyDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2-Spalten Layout */}
|
||||
{/* 3-Spalten Layout */}
|
||||
<div className={styles.layout}>
|
||||
{/* Links: Info */}
|
||||
{/* ======== Linke Spalte: Stammdaten ======== */}
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>Unternehmensdaten</h2>
|
||||
<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 && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>E-Mail</span>
|
||||
|
|
@ -187,7 +207,7 @@ export function CompanyDetailPage() {
|
|||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.infoLabel}>Erstellt am</span>
|
||||
<span className={styles.infoLabel}>Erstellt</span>
|
||||
<span className={styles.infoValue}>
|
||||
{formatDate(company.createdAt)}
|
||||
</span>
|
||||
|
|
@ -227,59 +247,30 @@ export function CompanyDetailPage() {
|
|||
</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' }}>
|
||||
{/* Kontakte */}
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>
|
||||
<div className={styles.cardTitleRow}>
|
||||
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
|
||||
Kontakte ({company._count?.contacts ?? contacts.length})
|
||||
</h2>
|
||||
</div>
|
||||
{contacts.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
|
||||
Keine Kontakte vorhanden
|
||||
</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table className={styles.compactTable}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<th
|
||||
style={{
|
||||
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>
|
||||
<th>Name</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -290,38 +281,13 @@ export function CompanyDetailPage() {
|
|||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
data-clickable="true"
|
||||
onClick={() => navigate(`/crm/contacts/${c.id}`)}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<td style={{ fontWeight: 500 }}>{name}</td>
|
||||
<td style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{c.position ?? '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{c.email ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -332,81 +298,40 @@ export function CompanyDetailPage() {
|
|||
|
||||
{/* Vorgänge */}
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>
|
||||
<div className={styles.cardTitleRow}>
|
||||
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
|
||||
Vorgänge ({company._count?.deals ?? deals.length})
|
||||
</h2>
|
||||
</div>
|
||||
{deals.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
|
||||
Keine Vorgänge vorhanden
|
||||
</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table className={styles.compactTable}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<th
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
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>
|
||||
<th>Titel</th>
|
||||
<th>Stufe</th>
|
||||
<th style={{ textAlign: 'right' }}>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deals.map((deal) => (
|
||||
<tr
|
||||
key={deal.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
data-clickable="true"
|
||||
onClick={() => navigate(`/crm/deals/${deal.id}`)}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{deal.title}
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<span style={{ fontWeight: 500 }}>{deal.title}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.375rem',
|
||||
padding: '0 0.25rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.6875rem',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 500,
|
||||
background: STATUS_COLORS[deal.status].bg,
|
||||
color: STATUS_COLORS[deal.status].color,
|
||||
|
|
@ -416,20 +341,19 @@ export function CompanyDetailPage() {
|
|||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0' }}>
|
||||
<td style={{ minWidth: 80 }}>
|
||||
{deal.stage && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
fontSize: '0.8125rem',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: deal.stage.color,
|
||||
display: 'inline-block',
|
||||
|
|
@ -439,14 +363,7 @@ export function CompanyDetailPage() {
|
|||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '0.5rem 0',
|
||||
textAlign: 'right',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<td style={{ textAlign: 'right', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{deal.value
|
||||
? currencyFormatter.format(parseFloat(deal.value))
|
||||
: '—'}
|
||||
|
|
@ -458,6 +375,15 @@ export function CompanyDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Beziehungen */}
|
||||
<CompanyRelationshipsCard companyId={company.id} />
|
||||
|
||||
{/* Verträge (Platzhalter) */}
|
||||
<ContractsCard
|
||||
companyId={company.id}
|
||||
contractCount={company._count?.contracts ?? 0}
|
||||
/>
|
||||
|
||||
{/* Lexware Office */}
|
||||
<LexwareSection
|
||||
entityType="company"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { useCreateCompany, useUpdateCompany } from '../hooks';
|
||||
import {
|
||||
useCreateCompany,
|
||||
useUpdateCompany,
|
||||
useIndustries,
|
||||
useAccountTypes,
|
||||
useTenantUsers,
|
||||
} from '../hooks';
|
||||
import type { Company } from '../types';
|
||||
|
||||
interface CompanyFormModalProps {
|
||||
|
|
@ -30,6 +36,11 @@ const inputStyle: React.CSSProperties = {
|
|||
color: 'var(--color-text)',
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
|
|
@ -47,9 +58,20 @@ export function CompanyFormModal({
|
|||
const updateMutation = useUpdateCompany();
|
||||
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 [name, setName] = useState('');
|
||||
const [industry, setIndustry] = useState('');
|
||||
const [industryId, setIndustryId] = useState('');
|
||||
const [accountTypeId, setAccountTypeId] = useState('');
|
||||
const [ownerId, setOwnerId] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
|
|
@ -65,7 +87,9 @@ export function CompanyFormModal({
|
|||
setError('');
|
||||
if (company) {
|
||||
setName(company.name);
|
||||
setIndustry(company.industry ?? '');
|
||||
setIndustryId(company.industryId ?? '');
|
||||
setAccountTypeId(company.accountTypeId ?? '');
|
||||
setOwnerId(company.ownerId ?? '');
|
||||
setEmail(company.email ?? '');
|
||||
setPhone(company.phone ?? '');
|
||||
setWebsite(company.website ?? '');
|
||||
|
|
@ -77,7 +101,9 @@ export function CompanyFormModal({
|
|||
setTagsInput(company.tags?.join(', ') ?? '');
|
||||
} else {
|
||||
setName('');
|
||||
setIndustry('');
|
||||
setIndustryId('');
|
||||
setAccountTypeId('');
|
||||
setOwnerId('');
|
||||
setEmail('');
|
||||
setPhone('');
|
||||
setWebsite('');
|
||||
|
|
@ -105,9 +131,19 @@ export function CompanyFormModal({
|
|||
.map((t) => t.trim())
|
||||
.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 = {
|
||||
name: name.trim(),
|
||||
...(industry ? { industry } : {}),
|
||||
...(industryId ? { industryId } : {}),
|
||||
...(accountTypeId ? { accountTypeId } : {}),
|
||||
...(ownerId ? { ownerId, ownerName } : {}),
|
||||
...(email ? { email } : {}),
|
||||
...(phone ? { phone } : {}),
|
||||
...(website ? { website } : {}),
|
||||
|
|
@ -183,12 +219,55 @@ export function CompanyFormModal({
|
|||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Branche</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={industry}
|
||||
onChange={(e) => setIndustry(e.target.value)}
|
||||
placeholder="z.B. Enterprise Software"
|
||||
/>
|
||||
<select
|
||||
style={selectStyle}
|
||||
value={industryId}
|
||||
onChange={(e) => setIndustryId(e.target.value)}
|
||||
>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
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}>Unternehmen</th>
|
||||
<th style={thStyle}>Pipeline</th>
|
||||
<th style={thStyle}>Stage</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Wert</th>
|
||||
<th style={{ ...thStyle, minWidth: 120 }}>Stage</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right', minWidth: 100 }}>Wert</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Aktionen</th>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
pipelinesApi,
|
||||
activitiesApi,
|
||||
companiesApi,
|
||||
industriesApi,
|
||||
accountTypesApi,
|
||||
relationshipTypesApi,
|
||||
companyRelationshipsApi,
|
||||
usersApi,
|
||||
lexwareContactsApi,
|
||||
lexwareVouchersApi,
|
||||
} from './api';
|
||||
|
|
@ -29,6 +34,13 @@ import type {
|
|||
CompaniesQueryParams,
|
||||
CreateCompanyPayload,
|
||||
UpdateCompanyPayload,
|
||||
CreateIndustryPayload,
|
||||
UpdateIndustryPayload,
|
||||
CreateAccountTypePayload,
|
||||
UpdateAccountTypePayload,
|
||||
CreateRelationshipTypePayload,
|
||||
UpdateRelationshipTypePayload,
|
||||
CreateCompanyRelationshipPayload,
|
||||
LexwareContactSearchParams,
|
||||
LexwareVouchersQueryParams,
|
||||
} from './types';
|
||||
|
|
@ -65,6 +77,27 @@ export const crmKeys = {
|
|||
['crm', 'companies', 'list', params] 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: {
|
||||
all: ['crm', 'lexware'] as const,
|
||||
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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -7,6 +7,113 @@
|
|||
export type ContactType = 'PERSON' | 'ORGANIZATION';
|
||||
export type DealStatus = 'OPEN' | 'WON' | 'LOST';
|
||||
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 ---
|
||||
|
||||
|
|
@ -76,7 +183,8 @@ export type UpdateContactPayload = Partial<CreateContactPayload>;
|
|||
export interface Activity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
contactId: string;
|
||||
contactId: string | null;
|
||||
companyId: string | null;
|
||||
type: ActivityType;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
|
|
@ -91,11 +199,16 @@ export interface Activity {
|
|||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
companyName: string | null;
|
||||
};
|
||||
} | null;
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CreateActivityPayload {
|
||||
contactId: string;
|
||||
contactId?: string;
|
||||
companyId?: string;
|
||||
type: ActivityType;
|
||||
subject: string;
|
||||
description?: string;
|
||||
|
|
@ -103,7 +216,7 @@ export interface CreateActivityPayload {
|
|||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type UpdateActivityPayload = Partial<Omit<CreateActivityPayload, 'contactId'>>;
|
||||
export type UpdateActivityPayload = Partial<Omit<CreateActivityPayload, 'contactId' | 'companyId'>>;
|
||||
|
||||
// --- Pipeline + Stage ---
|
||||
|
||||
|
|
@ -208,6 +321,10 @@ export interface Company {
|
|||
tenantId: string;
|
||||
name: string;
|
||||
industry: string | null;
|
||||
industryId: string | null;
|
||||
accountTypeId: string | null;
|
||||
ownerId: string | null;
|
||||
ownerName: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
|
|
@ -226,7 +343,9 @@ export interface Company {
|
|||
lexwareContactId: string | null;
|
||||
lexwareContactVersion: number | 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?: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
|
|
@ -237,11 +356,18 @@ export interface Company {
|
|||
isActive: boolean;
|
||||
}[];
|
||||
deals?: Deal[];
|
||||
relationships?: CompanyRelationship[];
|
||||
relatedRelationships?: CompanyRelationship[];
|
||||
contracts?: Contract[];
|
||||
}
|
||||
|
||||
export interface CreateCompanyPayload {
|
||||
name: string;
|
||||
industry?: string;
|
||||
industryId?: string;
|
||||
accountTypeId?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
|
|
@ -307,6 +433,8 @@ export interface ActivitiesQueryParams {
|
|||
page?: number;
|
||||
pageSize?: number;
|
||||
contactId?: string;
|
||||
companyId?: string;
|
||||
includeContacts?: boolean;
|
||||
type?: ActivityType;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue