mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
feat(crm): Phase 1 backend schema expansion + frontend integration
Backend (CRM-Expert Phase 1): - New enums: ContactSource, EntityStatus, CompanySize, OwnerRole, LostReason, EmailType, PhoneType - Contact: add linkedinUrl, birthday, source, department, status - Company: add vatId, taxId, tradeRegisterNumber, registerCourt, companySize, deliveryAddress, dataEnrichedAt/Source, status - Deal: add lostReason + lostReasonText (required when status=LOST) - Multi-value emails/phones tables (contact_emails, contact_phones) - Owner m:n model (contact_owners, company_owners, deal_owners) - Redis Pub/Sub CRM events (crm.contact.created, crm.deal.won, etc.) - Activity due_soon scheduler (cron every 15 min) - SQL migration with data migration for existing records Frontend integration: - types.ts: all new enums, interfaces, label maps - api.ts: owner CRUD endpoints (add/remove for contacts/companies/deals) - hooks.ts: 6 new owner mutation hooks - ContactFormModal: LinkedIn, birthday, source, department, status fields - ContactDetailPage: display new fields (LinkedIn, department, birthday, source, status badge) - CompanyDetailPage: display vatId, taxId, trade register, company size, delivery address, data enrichment info - DealFormModal: lost reason dropdown + text (shown when status=LOST) - DealDetailPage: display lost reason with label - CompaniesPage: EntityStatus-aware status dots (ACTIVE/INACTIVE/BLOCKED) - ActivityType: add FOLLOWUP to all label maps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d8847fafa
commit
48df3c3144
41 changed files with 2818 additions and 240 deletions
|
|
@ -1649,4 +1649,127 @@ Format: `## YYYY-MM-DD | CRM-Backend: [Betreff]`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 | CRM-Backend: Phase 1 — Schema-Expansion, Owner, Lost-Reason, Events
|
||||||
|
|
||||||
|
### Zusammenfassung
|
||||||
|
|
||||||
|
Umsetzung der Phase 1 ("SOFORT") gemaess Architektur-Briefing. Alle Arbeitspakete ohne externe Abhaengigkeiten implementiert.
|
||||||
|
|
||||||
|
### 1. Prisma Schema + SQL Migration
|
||||||
|
|
||||||
|
**Neue Enums (7):** `ContactSource`, `EntityStatus`, `CompanySize`, `OwnerRole`, `LostReason`, `EmailType`, `PhoneType` + `FOLLOWUP` zu ActivityType.
|
||||||
|
|
||||||
|
**Neue Felder auf Contact:** `linkedinUrl`, `birthday`, `source` (ContactSource), `department`, `status` (EntityStatus).
|
||||||
|
|
||||||
|
**Neue Felder auf Company:** `vatId`, `taxId`, `tradeRegisterNumber`, `registerCourt`, `companySize`, `deliveryStreet/Zip/City/Country`, `dataEnrichedAt`, `dataEnrichedSource`, `status` (EntityStatus).
|
||||||
|
|
||||||
|
**Neue Felder auf Deal:** `lostReason` (LostReason), `lostReasonText`.
|
||||||
|
|
||||||
|
**Neue Tabellen (5):** `contact_emails`, `contact_phones`, `contact_owners`, `company_owners`, `deal_owners`.
|
||||||
|
|
||||||
|
**Migration:** `prisma/migrations/20260312_phase1_schema_expansion/migration.sql` — inkl. Daten-Migration (isActive→status, email/phone→contact_emails/phones, ownerId→company_owners).
|
||||||
|
|
||||||
|
### 2. Multi-Value E-Mails & Telefone (Breaking Change)
|
||||||
|
|
||||||
|
Bisherige Einzel-Felder `email`, `phone`, `mobile` bleiben als **deprecated Legacy** erhalten. Neue Multi-Value Tabellen `contact_emails` und `contact_phones` dienen als primaere Datenquelle. Legacy-Felder werden automatisch aus Primary-Eintraegen synchronisiert.
|
||||||
|
|
||||||
|
**Neue Endpoints/Verhalten:**
|
||||||
|
- `POST /contacts` akzeptiert jetzt `emails: [{email, type, isPrimary}]` und `phones: [{phone, type, isPrimary}]`
|
||||||
|
- `PATCH /contacts/:id` mit Replace-Strategie (delete all + recreate)
|
||||||
|
- Analog fuer Companies
|
||||||
|
- Lexware-Import erzeugt automatisch Multi-Value Eintraege
|
||||||
|
- `status` Feld (ACTIVE/INACTIVE/BLOCKED) ersetzt `isActive` Boolean — beide werden synchron gehalten
|
||||||
|
|
||||||
|
**DTOs:**
|
||||||
|
- `CreateEmailDto`: `{email, type?: EmailType, isPrimary?: boolean}`
|
||||||
|
- `CreatePhoneDto`: `{phone, type?: PhoneType, isPrimary?: boolean}`
|
||||||
|
- `EntityStatus`: ACTIVE, INACTIVE, BLOCKED
|
||||||
|
- `CompanySize`: SIZE_1_10, SIZE_11_50, SIZE_51_200, SIZE_201_500, SIZE_500_PLUS
|
||||||
|
- `ContactSource`: TRADE_FAIR, REFERRAL, WEBSITE, COLD_CALL, IMPORT, BUSINESS_CARD, OTHER
|
||||||
|
|
||||||
|
### 3. Owner m:n Modell
|
||||||
|
|
||||||
|
Jeder Contact, Company und Deal hat jetzt ein mehrstufiges Ownership-Modell mit Rollen.
|
||||||
|
|
||||||
|
**Neue Endpoints:**
|
||||||
|
```
|
||||||
|
POST /crm/contacts/:id/owners Body: {userId, role?}
|
||||||
|
DELETE /crm/contacts/:id/owners/:userId
|
||||||
|
|
||||||
|
POST /crm/companies/:id/owners Body: {userId, role?}
|
||||||
|
DELETE /crm/companies/:id/owners/:userId
|
||||||
|
|
||||||
|
POST /crm/deals/:id/owners Body: {userId, role?}
|
||||||
|
DELETE /crm/deals/:id/owners/:userId
|
||||||
|
```
|
||||||
|
|
||||||
|
**OwnerRole Enum:** OWNER, MEMBER, WATCHER.
|
||||||
|
|
||||||
|
Bei `create()` wird der erstellende User automatisch als OWNER eingetragen. Upsert-Verhalten: Wenn Owner bereits existiert, wird nur die Rolle aktualisiert.
|
||||||
|
|
||||||
|
### 4. Lost-Reason bei Deals
|
||||||
|
|
||||||
|
**Neue Felder:** `lostReason` (Enum: PRICE, TIMING, COMPETITOR, NO_NEED, OTHER), `lostReasonText` (Freitext).
|
||||||
|
|
||||||
|
**Validierung:**
|
||||||
|
- `PATCH /deals/:id` mit `status: 'LOST'` erfordert `lostReason` (im DTO oder bereits gesetzt) — sonst 400 BadRequest.
|
||||||
|
- `PATCH /deals/:id` mit `status: 'WON'` loescht automatisch `lostReason` und `lostReasonText`.
|
||||||
|
|
||||||
|
### 5. Redis Pub/Sub Events
|
||||||
|
|
||||||
|
**CrmEventPublisher** (global verfuegbar): Publiziert Events auf Redis Channels im Format `app:crm:events:{type}`.
|
||||||
|
|
||||||
|
**Event-Typen:**
|
||||||
|
- `crm.contact.created` — nach Contact-Erstellung
|
||||||
|
- `crm.contact.updated` — nach Contact-Update
|
||||||
|
- `crm.deal.created` — nach Deal-Erstellung
|
||||||
|
- `crm.deal.stage_changed` — bei Stage-Aenderung (payload: previousStageId, newStageId)
|
||||||
|
- `crm.deal.won` — bei Deal WON (payload: value, currency)
|
||||||
|
- `crm.deal.lost` — bei Deal LOST (payload: lostReason)
|
||||||
|
- `crm.activity.due_soon` — Activities faellig in 24h (Cron alle 15 Min)
|
||||||
|
|
||||||
|
**Event-Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "crm.contact.created",
|
||||||
|
"tenantId": "uuid",
|
||||||
|
"entityId": "uuid",
|
||||||
|
"userId": "uuid",
|
||||||
|
"timestamp": "ISO-8601",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response-Aenderungen fuer Frontend
|
||||||
|
|
||||||
|
Alle Entity-Responses (Contact, Company, Deal) enthalten jetzt zusaetzlich:
|
||||||
|
- `emails: [{id, email, type, isPrimary, createdAt}]`
|
||||||
|
- `phones: [{id, phone, type, isPrimary, createdAt}]`
|
||||||
|
- `owners: [{id, tenantId, userId, role, createdAt}]`
|
||||||
|
- `status: 'ACTIVE' | 'INACTIVE' | 'BLOCKED'`
|
||||||
|
|
||||||
|
Contact zusaetzlich: `linkedinUrl`, `birthday`, `source`, `department`.
|
||||||
|
Company zusaetzlich: `vatId`, `taxId`, `tradeRegisterNumber`, `registerCourt`, `companySize`, `deliveryStreet/Zip/City/Country`.
|
||||||
|
Deal zusaetzlich: `lostReason`, `lostReasonText`.
|
||||||
|
|
||||||
|
### Geaenderte Dateien (18) + Neue Dateien (10)
|
||||||
|
|
||||||
|
Siehe Plan-Datei fuer komplette Dateiliste. Wichtigste neue Dateien:
|
||||||
|
- `src/common/dto/contact-info.dto.ts` — Shared DTOs + Enums
|
||||||
|
- `src/common/dto/owner.dto.ts` — AddOwnerDto + OwnerRole
|
||||||
|
- `src/owners/owners.service.ts` + `owners.module.ts` — Shared Owner CRUD
|
||||||
|
- `src/events/crm-event-publisher.service.ts` — CRM Event Publisher
|
||||||
|
- `src/events/crm-events.module.ts` — Global Events Module
|
||||||
|
- `src/events/activity-due-soon.scheduler.ts` — Cron-Job
|
||||||
|
|
||||||
|
### TODO fuer Frontend
|
||||||
|
|
||||||
|
1. `types.ts` um neue Felder erweitern (emails[], phones[], owners[], status, linkedinUrl, birthday, source, department, lostReason, etc.)
|
||||||
|
2. Contact/Company Formulare um Multi-Value Email/Phone Eingabe erweitern
|
||||||
|
3. Owner-Management UI (Zuweisen/Entfernen von Owners)
|
||||||
|
4. Deal-Detail: Lost-Reason Feld bei Status LOST einblenden
|
||||||
|
5. EntityStatus Filter in Listen verwenden (statt isActive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CRM-Service - Zusammenfassung
|
# CRM-Service - Zusammenfassung
|
||||||
|
|
||||||
## Stand: 2026-03-11
|
## Stand: 2026-03-12
|
||||||
|
|
||||||
### Was wurde erstellt
|
### Was wurde erstellt
|
||||||
|
|
||||||
|
|
@ -25,12 +25,14 @@ packages/crm-service/
|
||||||
prisma/ — CrmPrismaService (eigener Client)
|
prisma/ — CrmPrismaService (eigener Client)
|
||||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter, Shared DTOs (contact-info, owner)
|
||||||
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push, industryId, accountTypeId, ownerId)
|
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push)
|
||||||
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
|
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events)
|
||||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK; contactId+companyId optional)
|
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional)
|
||||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
||||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers
|
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events
|
||||||
|
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
||||||
|
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
||||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||||
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
||||||
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
||||||
|
|
@ -53,12 +55,17 @@ packages/crm-service/
|
||||||
|
|
||||||
### Datenbank-Modelle (app_crm Schema)
|
### Datenbank-Modelle (app_crm Schema)
|
||||||
|
|
||||||
- **Company** — Unternehmen mit industryId, accountTypeId, ownerId, Lexware-Verknuepfung
|
- **Company** — Unternehmen mit industryId, accountTypeId, Multi-Value emails/phones, Owner m:n, EntityStatus, vatId/taxId/tradeRegisterNumber/companySize, Lexware-Verknuepfung
|
||||||
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
|
- **Contact** — Kontakte mit Multi-Value emails/phones, Owner m:n, EntityStatus, linkedinUrl/birthday/source/department, Lexware-Verknuepfung
|
||||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1)
|
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) + FOLLOWUP-Typ
|
||||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||||
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||||
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
|
- **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events
|
||||||
|
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
|
||||||
|
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
|
||||||
|
- **ContactOwner** — Owner m:n fuer Contacts (Rollen: OWNER/MEMBER/WATCHER)
|
||||||
|
- **CompanyOwner** — Owner m:n fuer Companies
|
||||||
|
- **DealOwner** — Owner m:n fuer Deals
|
||||||
- **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant)
|
- **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant)
|
||||||
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
||||||
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
||||||
|
|
@ -97,6 +104,12 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete |
|
||||||
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
|
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete |
|
||||||
|
| POST | /api/v1/crm/contacts/:id/owners | Owner hinzufuegen |
|
||||||
|
| DELETE | /api/v1/crm/contacts/:id/owners/:userId | Owner entfernen |
|
||||||
|
| POST | /api/v1/crm/companies/:id/owners | Owner hinzufuegen |
|
||||||
|
| DELETE | /api/v1/crm/companies/:id/owners/:userId | Owner entfernen |
|
||||||
|
| POST | /api/v1/crm/deals/:id/owners | Owner hinzufuegen |
|
||||||
|
| DELETE | /api/v1/crm/deals/:id/owners/:userId | Owner entfernen |
|
||||||
| GET/POST | /api/v1/crm/activities | Liste / Erstellen (companyId+includeContacts fuer aggregierten Feed) |
|
| GET/POST | /api/v1/crm/activities | Liste / Erstellen (companyId+includeContacts fuer aggregierten Feed) |
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete |
|
||||||
| GET/POST | /api/v1/crm/industries | Branchen verwalten |
|
| GET/POST | /api/v1/crm/industries | Branchen verwalten |
|
||||||
|
|
@ -162,20 +175,24 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
|
|
||||||
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
||||||
|
|
||||||
- Prisma Migrationen:
|
- Prisma Migrationen: siehe Abschnitt "Migrationen" oben
|
||||||
- `20260310163211_init` — Initiales Schema
|
|
||||||
- `20260310183117_add_companies` — Company-Entity
|
### Migrationen
|
||||||
- `20260310_add_lexware_integration` — Lexware Office Integration
|
|
||||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul (Industry, AccountType, RelationshipType, CompanyRelationship, Contract, Activity companyId)
|
- `20260310163211_init` — Initiales Schema
|
||||||
|
- `20260310183117_add_companies` — Company-Entity
|
||||||
|
- `20260310_add_lexware_integration` — Lexware Office Integration
|
||||||
|
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||||
|
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||||
|
|
||||||
### Naechste Schritte
|
### Naechste Schritte
|
||||||
|
|
||||||
1. Migration `20260311_add_company_detail_overhaul` auf Server anwenden
|
1. Migration `20260312_phase1_schema_expansion` auf Server anwenden
|
||||||
2. Seed-Data (Industries, AccountTypes, RelationshipTypes) ausfuehren
|
2. Container neu bauen und deployen
|
||||||
3. Container neu bauen und deployen
|
3. Frontend: Multi-Value Email/Phone UI implementieren
|
||||||
4. Frontend testen: CompanyDetailPage 3-Spalten Layout
|
4. Frontend: Owner-Management UI
|
||||||
5. CRM-Einstellungen: Branchen/Kontotypen/Beziehungstypen verwalten
|
5. Frontend: EntityStatus statt isActive verwenden
|
||||||
6. CompanyFormModal: Dropdowns testen
|
6. Frontend: LostReason bei Deal-Verlust einblenden
|
||||||
7. Activity Feed: Aggregierten Feed testen
|
7. Phase 2: Office 365 E-Mail Integration (Planer-Briefing vorhanden)
|
||||||
8. Kanban-Board fuer Vorgaenge
|
8. Phase 3: Kontakt-Zusammenfuehrung (Merge)
|
||||||
9. Vertraege-UI implementieren (DB-Modell bereits vorhanden)
|
9. Phase 4: Aktivitaets-Erweiterung + Dashboard Widgets
|
||||||
|
|
|
||||||
|
|
@ -91,23 +91,42 @@ model Company {
|
||||||
ownerId String? @map("owner_id") @db.Uuid
|
ownerId String? @map("owner_id") @db.Uuid
|
||||||
ownerName String? @map("owner_name") @db.VarChar(200)
|
ownerName String? @map("owner_name") @db.VarChar(200)
|
||||||
|
|
||||||
// Kontaktdaten
|
// Phase 1: Registerdaten
|
||||||
|
vatId String? @map("vat_id") @db.VarChar(50)
|
||||||
|
taxId String? @map("tax_id") @db.VarChar(50)
|
||||||
|
tradeRegisterNumber String? @map("trade_register_number") @db.VarChar(100)
|
||||||
|
registerCourt String? @map("register_court") @db.VarChar(200)
|
||||||
|
companySize CompanySize? @map("company_size")
|
||||||
|
|
||||||
|
// Kontaktdaten (Legacy-Einzelfelder, deprecated — siehe emails[]/phones[])
|
||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
phone String? @db.VarChar(50)
|
phone String? @db.VarChar(50)
|
||||||
website String? @db.VarChar(500)
|
website String? @db.VarChar(500)
|
||||||
|
|
||||||
// Adresse
|
// Adresse (Hauptsitz)
|
||||||
street String? @db.VarChar(200)
|
street String? @db.VarChar(200)
|
||||||
zip String? @db.VarChar(20)
|
zip String? @db.VarChar(20)
|
||||||
city String? @db.VarChar(100)
|
city String? @db.VarChar(100)
|
||||||
state String? @db.VarChar(100)
|
state String? @db.VarChar(100)
|
||||||
country String? @default("DE") @db.VarChar(2)
|
country String? @default("DE") @db.VarChar(2)
|
||||||
|
|
||||||
|
// Adresse (Lieferung)
|
||||||
|
deliveryStreet String? @map("delivery_street") @db.VarChar(200)
|
||||||
|
deliveryZip String? @map("delivery_zip") @db.VarChar(20)
|
||||||
|
deliveryCity String? @map("delivery_city") @db.VarChar(100)
|
||||||
|
deliveryCountry String? @map("delivery_country") @db.VarChar(2)
|
||||||
|
|
||||||
// Zusaetzlich
|
// Zusaetzlich
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
// Status (Phase 1: EntityStatus Enum ersetzt isActive)
|
||||||
|
status EntityStatus @default(ACTIVE)
|
||||||
|
isActive Boolean @default(true) @map("is_active") // DEPRECATED: Synchron gehalten
|
||||||
|
|
||||||
|
// Datenanreicherung
|
||||||
|
dataEnrichedAt DateTime? @map("data_enriched_at")
|
||||||
|
dataEnrichedSource String? @map("data_enriched_source") @db.VarChar(200)
|
||||||
|
|
||||||
// Lexware Office Integration
|
// Lexware Office Integration
|
||||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||||
|
|
@ -131,6 +150,9 @@ model Company {
|
||||||
relationships CompanyRelationship[] @relation("companyRelationships")
|
relationships CompanyRelationship[] @relation("companyRelationships")
|
||||||
relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships")
|
relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships")
|
||||||
contracts Contract[]
|
contracts Contract[]
|
||||||
|
emails ContactEmail[]
|
||||||
|
phones ContactPhone[]
|
||||||
|
owners CompanyOwner[]
|
||||||
|
|
||||||
@@unique([tenantId, lexwareContactId])
|
@@unique([tenantId, lexwareContactId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|
@ -139,6 +161,7 @@ model Company {
|
||||||
@@index([tenantId, industryId])
|
@@index([tenantId, industryId])
|
||||||
@@index([tenantId, accountTypeId])
|
@@index([tenantId, accountTypeId])
|
||||||
@@index([tenantId, isActive])
|
@@index([tenantId, isActive])
|
||||||
|
@@index([tenantId, status])
|
||||||
@@map("companies")
|
@@map("companies")
|
||||||
@@schema("app_crm")
|
@@schema("app_crm")
|
||||||
}
|
}
|
||||||
|
|
@ -160,12 +183,18 @@ model Contact {
|
||||||
companyName String? @map("company_name") @db.VarChar(200)
|
companyName String? @map("company_name") @db.VarChar(200)
|
||||||
position String? @db.VarChar(200)
|
position String? @db.VarChar(200)
|
||||||
|
|
||||||
// Kontaktdaten
|
// Kontaktdaten (Legacy-Einzelfelder, deprecated — siehe emails[]/phones[])
|
||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
phone String? @db.VarChar(50)
|
phone String? @db.VarChar(50)
|
||||||
mobile String? @db.VarChar(50)
|
mobile String? @db.VarChar(50)
|
||||||
website String? @db.VarChar(500)
|
website String? @db.VarChar(500)
|
||||||
|
|
||||||
|
// Phase 1: Neue Felder
|
||||||
|
linkedinUrl String? @map("linkedin_url") @db.VarChar(500)
|
||||||
|
birthday DateTime?
|
||||||
|
source ContactSource?
|
||||||
|
department String? @db.VarChar(200)
|
||||||
|
|
||||||
// Adresse
|
// Adresse
|
||||||
street String? @db.VarChar(200)
|
street String? @db.VarChar(200)
|
||||||
zip String? @db.VarChar(20)
|
zip String? @db.VarChar(20)
|
||||||
|
|
@ -177,7 +206,9 @@ model Contact {
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
// Status (Phase 1: EntityStatus Enum ersetzt isActive)
|
||||||
|
status EntityStatus @default(ACTIVE)
|
||||||
|
isActive Boolean @default(true) @map("is_active") // DEPRECATED: Synchron gehalten
|
||||||
|
|
||||||
// Lexware Office Integration
|
// Lexware Office Integration
|
||||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||||
|
|
@ -196,6 +227,9 @@ model Contact {
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
lexwareVouchers LexwareVoucher[]
|
lexwareVouchers LexwareVoucher[]
|
||||||
|
emails ContactEmail[]
|
||||||
|
phones ContactPhone[]
|
||||||
|
owners ContactOwner[]
|
||||||
|
|
||||||
@@unique([tenantId, lexwareContactId])
|
@@unique([tenantId, lexwareContactId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|
@ -204,6 +238,7 @@ model Contact {
|
||||||
@@index([tenantId, companyName])
|
@@index([tenantId, companyName])
|
||||||
@@index([tenantId, lastName, firstName])
|
@@index([tenantId, lastName, firstName])
|
||||||
@@index([tenantId, isActive])
|
@@index([tenantId, isActive])
|
||||||
|
@@index([tenantId, status])
|
||||||
@@map("contacts")
|
@@map("contacts")
|
||||||
@@schema("app_crm")
|
@@schema("app_crm")
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +292,74 @@ enum ActivityType {
|
||||||
EMAIL
|
EMAIL
|
||||||
MEETING
|
MEETING
|
||||||
TASK
|
TASK
|
||||||
|
FOLLOWUP
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Phase 1 Enums — Kontakt-Herkunft, Status, Owner, etc.
|
||||||
|
// --------------------------------------------------------
|
||||||
|
enum ContactSource {
|
||||||
|
TRADE_FAIR
|
||||||
|
REFERRAL
|
||||||
|
WEBSITE
|
||||||
|
COLD_CALL
|
||||||
|
IMPORT
|
||||||
|
BUSINESS_CARD
|
||||||
|
OTHER
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EntityStatus {
|
||||||
|
ACTIVE
|
||||||
|
INACTIVE
|
||||||
|
BLOCKED
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CompanySize {
|
||||||
|
SIZE_1_10
|
||||||
|
SIZE_11_50
|
||||||
|
SIZE_51_200
|
||||||
|
SIZE_201_500
|
||||||
|
SIZE_500_PLUS
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OwnerRole {
|
||||||
|
OWNER
|
||||||
|
MEMBER
|
||||||
|
WATCHER
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LostReason {
|
||||||
|
PRICE
|
||||||
|
TIMING
|
||||||
|
COMPETITOR
|
||||||
|
NO_NEED
|
||||||
|
OTHER
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmailType {
|
||||||
|
WORK
|
||||||
|
PERSONAL
|
||||||
|
OTHER
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PhoneType {
|
||||||
|
OFFICE
|
||||||
|
MOBILE
|
||||||
|
FAX
|
||||||
|
|
||||||
@@schema("app_crm")
|
@@schema("app_crm")
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +495,10 @@ model Deal {
|
||||||
closedAt DateTime? @map("closed_at")
|
closedAt DateTime? @map("closed_at")
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
|
|
||||||
|
// Phase 1: Lost-Reason
|
||||||
|
lostReason LostReason? @map("lost_reason")
|
||||||
|
lostReasonText String? @map("lost_reason_text") @db.Text
|
||||||
|
|
||||||
// Audit-Trail
|
// Audit-Trail
|
||||||
createdBy String @map("created_by") @db.Uuid
|
createdBy String @map("created_by") @db.Uuid
|
||||||
updatedBy String? @map("updated_by") @db.Uuid
|
updatedBy String? @map("updated_by") @db.Uuid
|
||||||
|
|
@ -405,6 +512,7 @@ model Deal {
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
dealVouchers DealVoucher[]
|
dealVouchers DealVoucher[]
|
||||||
|
owners DealOwner[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, pipelineId])
|
@@index([tenantId, pipelineId])
|
||||||
|
|
@ -507,6 +615,108 @@ model DealVoucher {
|
||||||
@@schema("app_crm")
|
@@schema("app_crm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ContactEmail - Multi-Value E-Mail-Adressen (Phase 1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ContactEmail {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
contactId String? @map("contact_id") @db.Uuid
|
||||||
|
companyId String? @map("company_id") @db.Uuid
|
||||||
|
email String @db.VarChar(255)
|
||||||
|
type EmailType @default(WORK)
|
||||||
|
isPrimary Boolean @default(false) @map("is_primary")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||||
|
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([contactId])
|
||||||
|
@@index([companyId])
|
||||||
|
@@map("contact_emails")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ContactPhone - Multi-Value Telefonnummern (Phase 1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ContactPhone {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
contactId String? @map("contact_id") @db.Uuid
|
||||||
|
companyId String? @map("company_id") @db.Uuid
|
||||||
|
phone String @db.VarChar(50)
|
||||||
|
type PhoneType @default(OFFICE)
|
||||||
|
isPrimary Boolean @default(false) @map("is_primary")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||||
|
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([contactId])
|
||||||
|
@@index([companyId])
|
||||||
|
@@map("contact_phones")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// ContactOwner - Zustaendige pro Kontakt (m:n, Phase 1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model ContactOwner {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
contactId String @map("contact_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
role OwnerRole @default(OWNER)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([contactId, userId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, contactId])
|
||||||
|
@@map("contact_owners")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CompanyOwner - Zustaendige pro Unternehmen (m:n, Phase 1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model CompanyOwner {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
companyId String @map("company_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
role OwnerRole @default(OWNER)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([companyId, userId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, companyId])
|
||||||
|
@@map("company_owners")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DealOwner - Zustaendige pro Deal (m:n, Phase 1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model DealOwner {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
dealId String @map("deal_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
role OwnerRole @default(OWNER)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([dealId, userId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, dealId])
|
||||||
|
@@map("deal_owners")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar)
|
// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Phase 1: Schema Expansion
|
||||||
|
-- Neue Enums, Felder, Multi-Value Tabellen, Owner-Tabellen
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- WICHTIG: ALTER TYPE ... ADD VALUE kann NICHT in einer Transaction laufen.
|
||||||
|
-- Diese Migration muss daher ausserhalb von BEGIN/COMMIT ausgefuehrt werden,
|
||||||
|
-- oder der ADD VALUE muss separat laufen.
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 1. Neue Enums erstellen
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."ContactSource" AS ENUM (
|
||||||
|
'TRADE_FAIR', 'REFERRAL', 'WEBSITE', 'COLD_CALL',
|
||||||
|
'IMPORT', 'BUSINESS_CARD', 'OTHER'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."EntityStatus" AS ENUM (
|
||||||
|
'ACTIVE', 'INACTIVE', 'BLOCKED'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."CompanySize" AS ENUM (
|
||||||
|
'SIZE_1_10', 'SIZE_11_50', 'SIZE_51_200', 'SIZE_201_500', 'SIZE_500_PLUS'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."OwnerRole" AS ENUM (
|
||||||
|
'OWNER', 'MEMBER', 'WATCHER'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."LostReason" AS ENUM (
|
||||||
|
'PRICE', 'TIMING', 'COMPETITOR', 'NO_NEED', 'OTHER'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."EmailType" AS ENUM (
|
||||||
|
'WORK', 'PERSONAL', 'OTHER'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."PhoneType" AS ENUM (
|
||||||
|
'OFFICE', 'MOBILE', 'FAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- FOLLOWUP zum ActivityType Enum hinzufuegen (NICHT transaktionsfaehig!)
|
||||||
|
ALTER TYPE "app_crm"."ActivityType" ADD VALUE IF NOT EXISTS 'FOLLOWUP';
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 2. Neue Spalten auf contacts
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contacts"
|
||||||
|
ADD COLUMN "linkedin_url" VARCHAR(500),
|
||||||
|
ADD COLUMN "birthday" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "source" "app_crm"."ContactSource",
|
||||||
|
ADD COLUMN "department" VARCHAR(200),
|
||||||
|
ADD COLUMN "status" "app_crm"."EntityStatus" NOT NULL DEFAULT 'ACTIVE';
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 3. Neue Spalten auf companies
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."companies"
|
||||||
|
ADD COLUMN "vat_id" VARCHAR(50),
|
||||||
|
ADD COLUMN "tax_id" VARCHAR(50),
|
||||||
|
ADD COLUMN "trade_register_number" VARCHAR(100),
|
||||||
|
ADD COLUMN "register_court" VARCHAR(200),
|
||||||
|
ADD COLUMN "company_size" "app_crm"."CompanySize",
|
||||||
|
ADD COLUMN "delivery_street" VARCHAR(200),
|
||||||
|
ADD COLUMN "delivery_zip" VARCHAR(20),
|
||||||
|
ADD COLUMN "delivery_city" VARCHAR(100),
|
||||||
|
ADD COLUMN "delivery_country" VARCHAR(2),
|
||||||
|
ADD COLUMN "status" "app_crm"."EntityStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
ADD COLUMN "data_enriched_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "data_enriched_source" VARCHAR(200);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 4. Neue Spalten auf deals (Lost-Reason)
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."deals"
|
||||||
|
ADD COLUMN "lost_reason" "app_crm"."LostReason",
|
||||||
|
ADD COLUMN "lost_reason_text" TEXT;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 5. Daten-Migration: isActive → status
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE "app_crm"."contacts"
|
||||||
|
SET "status" = 'INACTIVE'
|
||||||
|
WHERE "is_active" = false;
|
||||||
|
|
||||||
|
UPDATE "app_crm"."companies"
|
||||||
|
SET "status" = 'INACTIVE'
|
||||||
|
WHERE "is_active" = false;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 6. Multi-Value: contact_emails Tabelle
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."contact_emails" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"contact_id" UUID,
|
||||||
|
"company_id" UUID,
|
||||||
|
"email" VARCHAR(255) NOT NULL,
|
||||||
|
"type" "app_crm"."EmailType" NOT NULL DEFAULT 'WORK',
|
||||||
|
"is_primary" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "contact_emails_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "contact_emails_contact_id_idx" ON "app_crm"."contact_emails"("contact_id");
|
||||||
|
CREATE INDEX "contact_emails_company_id_idx" ON "app_crm"."contact_emails"("company_id");
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contact_emails"
|
||||||
|
ADD CONSTRAINT "contact_emails_contact_id_fkey"
|
||||||
|
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contact_emails"
|
||||||
|
ADD CONSTRAINT "contact_emails_company_id_fkey"
|
||||||
|
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 7. Multi-Value: contact_phones Tabelle
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."contact_phones" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"contact_id" UUID,
|
||||||
|
"company_id" UUID,
|
||||||
|
"phone" VARCHAR(50) NOT NULL,
|
||||||
|
"type" "app_crm"."PhoneType" NOT NULL DEFAULT 'OFFICE',
|
||||||
|
"is_primary" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "contact_phones_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "contact_phones_contact_id_idx" ON "app_crm"."contact_phones"("contact_id");
|
||||||
|
CREATE INDEX "contact_phones_company_id_idx" ON "app_crm"."contact_phones"("company_id");
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contact_phones"
|
||||||
|
ADD CONSTRAINT "contact_phones_contact_id_fkey"
|
||||||
|
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contact_phones"
|
||||||
|
ADD CONSTRAINT "contact_phones_company_id_fkey"
|
||||||
|
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 8. Daten-Migration: Bestehende Emails/Phones migrieren
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
-- Contact Emails
|
||||||
|
INSERT INTO "app_crm"."contact_emails" ("id", "contact_id", "email", "type", "is_primary")
|
||||||
|
SELECT gen_random_uuid(), "id", "email", 'WORK'::"app_crm"."EmailType", true
|
||||||
|
FROM "app_crm"."contacts"
|
||||||
|
WHERE "email" IS NOT NULL AND "email" != '';
|
||||||
|
|
||||||
|
-- Contact Phones (phone → OFFICE, mobile → MOBILE)
|
||||||
|
INSERT INTO "app_crm"."contact_phones" ("id", "contact_id", "phone", "type", "is_primary")
|
||||||
|
SELECT gen_random_uuid(), "id", "phone", 'OFFICE'::"app_crm"."PhoneType", true
|
||||||
|
FROM "app_crm"."contacts"
|
||||||
|
WHERE "phone" IS NOT NULL AND "phone" != '';
|
||||||
|
|
||||||
|
INSERT INTO "app_crm"."contact_phones" ("id", "contact_id", "phone", "type", "is_primary")
|
||||||
|
SELECT gen_random_uuid(), "id", "mobile", 'MOBILE'::"app_crm"."PhoneType", false
|
||||||
|
FROM "app_crm"."contacts"
|
||||||
|
WHERE "mobile" IS NOT NULL AND "mobile" != '';
|
||||||
|
|
||||||
|
-- Company Emails
|
||||||
|
INSERT INTO "app_crm"."contact_emails" ("id", "company_id", "email", "type", "is_primary")
|
||||||
|
SELECT gen_random_uuid(), "id", "email", 'WORK'::"app_crm"."EmailType", true
|
||||||
|
FROM "app_crm"."companies"
|
||||||
|
WHERE "email" IS NOT NULL AND "email" != '';
|
||||||
|
|
||||||
|
-- Company Phones
|
||||||
|
INSERT INTO "app_crm"."contact_phones" ("id", "company_id", "phone", "type", "is_primary")
|
||||||
|
SELECT gen_random_uuid(), "id", "phone", 'OFFICE'::"app_crm"."PhoneType", true
|
||||||
|
FROM "app_crm"."companies"
|
||||||
|
WHERE "phone" IS NOT NULL AND "phone" != '';
|
||||||
|
|
||||||
|
-- Hinweis: Legacy-Spalten email, phone, mobile bleiben bestehen (deprecated)
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 9. Owner-Tabellen: contact_owners
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."contact_owners" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"contact_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "contact_owners_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "contact_owners_contact_id_user_id_key"
|
||||||
|
ON "app_crm"."contact_owners"("contact_id", "user_id");
|
||||||
|
CREATE INDEX "contact_owners_tenant_id_idx"
|
||||||
|
ON "app_crm"."contact_owners"("tenant_id");
|
||||||
|
CREATE INDEX "contact_owners_tenant_id_contact_id_idx"
|
||||||
|
ON "app_crm"."contact_owners"("tenant_id", "contact_id");
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."contact_owners"
|
||||||
|
ADD CONSTRAINT "contact_owners_contact_id_fkey"
|
||||||
|
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 10. Owner-Tabellen: company_owners
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."company_owners" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"company_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "company_owners_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "company_owners_company_id_user_id_key"
|
||||||
|
ON "app_crm"."company_owners"("company_id", "user_id");
|
||||||
|
CREATE INDEX "company_owners_tenant_id_idx"
|
||||||
|
ON "app_crm"."company_owners"("tenant_id");
|
||||||
|
CREATE INDEX "company_owners_tenant_id_company_id_idx"
|
||||||
|
ON "app_crm"."company_owners"("tenant_id", "company_id");
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."company_owners"
|
||||||
|
ADD CONSTRAINT "company_owners_company_id_fkey"
|
||||||
|
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Bestehende Company.ownerId → company_owners migrieren
|
||||||
|
INSERT INTO "app_crm"."company_owners" ("id", "tenant_id", "company_id", "user_id", "role")
|
||||||
|
SELECT gen_random_uuid(), "tenant_id", "id", "owner_id", 'OWNER'::"app_crm"."OwnerRole"
|
||||||
|
FROM "app_crm"."companies"
|
||||||
|
WHERE "owner_id" IS NOT NULL;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 11. Owner-Tabellen: deal_owners
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."deal_owners" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"deal_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "deal_owners_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "deal_owners_deal_id_user_id_key"
|
||||||
|
ON "app_crm"."deal_owners"("deal_id", "user_id");
|
||||||
|
CREATE INDEX "deal_owners_tenant_id_idx"
|
||||||
|
ON "app_crm"."deal_owners"("tenant_id");
|
||||||
|
CREATE INDEX "deal_owners_tenant_id_deal_id_idx"
|
||||||
|
ON "app_crm"."deal_owners"("tenant_id", "deal_id");
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."deal_owners"
|
||||||
|
ADD CONSTRAINT "deal_owners_deal_id_fkey"
|
||||||
|
FOREIGN KEY ("deal_id") REFERENCES "app_crm"."deals"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 12. Zusaetzliche Indexes fuer status
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE INDEX "contacts_tenant_id_status_idx"
|
||||||
|
ON "app_crm"."contacts"("tenant_id", "status");
|
||||||
|
CREATE INDEX "companies_tenant_id_status_idx"
|
||||||
|
ON "app_crm"."companies"("tenant_id", "status");
|
||||||
|
|
@ -20,6 +20,7 @@ import { AccountTypesModule } from './account-types/account-types.module';
|
||||||
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
||||||
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
||||||
import { TradeEventsModule } from './trade-events/trade-events.module';
|
import { TradeEventsModule } from './trade-events/trade-events.module';
|
||||||
|
import { CrmEventsModule } from './events/crm-events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -43,6 +44,7 @@ import { TradeEventsModule } from './trade-events/trade-events.module';
|
||||||
RelationshipTypesModule,
|
RelationshipTypesModule,
|
||||||
CompanyRelationshipsModule,
|
CompanyRelationshipsModule,
|
||||||
TradeEventsModule,
|
TradeEventsModule,
|
||||||
|
CrmEventsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
91
packages/crm-service/src/common/dto/contact-info.dto.ts
Normal file
91
packages/crm-service/src/common/dto/contact-info.dto.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// ============================================================
|
||||||
|
// Shared DTOs fuer Multi-Value E-Mails und Telefonnummern
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Enums (TS-Pendants zu Prisma Enums)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export enum EmailType {
|
||||||
|
WORK = 'WORK',
|
||||||
|
PERSONAL = 'PERSONAL',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PhoneType {
|
||||||
|
OFFICE = 'OFFICE',
|
||||||
|
MOBILE = 'MOBILE',
|
||||||
|
FAX = 'FAX',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ContactSource {
|
||||||
|
TRADE_FAIR = 'TRADE_FAIR',
|
||||||
|
REFERRAL = 'REFERRAL',
|
||||||
|
WEBSITE = 'WEBSITE',
|
||||||
|
COLD_CALL = 'COLD_CALL',
|
||||||
|
IMPORT = 'IMPORT',
|
||||||
|
BUSINESS_CARD = 'BUSINESS_CARD',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EntityStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
INACTIVE = 'INACTIVE',
|
||||||
|
BLOCKED = 'BLOCKED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CompanySize {
|
||||||
|
SIZE_1_10 = 'SIZE_1_10',
|
||||||
|
SIZE_11_50 = 'SIZE_11_50',
|
||||||
|
SIZE_51_200 = 'SIZE_51_200',
|
||||||
|
SIZE_201_500 = 'SIZE_201_500',
|
||||||
|
SIZE_500_PLUS = 'SIZE_500_PLUS',
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export class CreateEmailDto {
|
||||||
|
@ApiProperty({ maxLength: 255, description: 'E-Mail-Adresse' })
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: EmailType, default: EmailType.WORK })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EmailType)
|
||||||
|
type?: EmailType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePhoneDto {
|
||||||
|
@ApiProperty({ maxLength: 50, description: 'Telefonnummer' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: PhoneType, default: PhoneType.OFFICE })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PhoneType)
|
||||||
|
type?: PhoneType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
23
packages/crm-service/src/common/dto/owner.dto.ts
Normal file
23
packages/crm-service/src/common/dto/owner.dto.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// ============================================================
|
||||||
|
// Shared DTO fuer Owner-Operationen (Contact, Company, Deal)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { IsUUID, IsOptional, IsEnum } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export enum OwnerRole {
|
||||||
|
OWNER = 'OWNER',
|
||||||
|
MEMBER = 'MEMBER',
|
||||||
|
WATCHER = 'WATCHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddOwnerDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'User-ID des Owners (aus platform_core)' })
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: OwnerRole, default: OwnerRole.OWNER })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OwnerRole)
|
||||||
|
role?: OwnerRole;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ import { CompaniesService } from './companies.service';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||||
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
|
import { OwnersService } from '../owners/owners.service';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,7 +37,10 @@ import {
|
||||||
@UseGuards(TenantGuard)
|
@UseGuards(TenantGuard)
|
||||||
@Controller('companies')
|
@Controller('companies')
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(private readonly companiesService: CompaniesService) {}
|
constructor(
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
|
private readonly ownersService: OwnersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
|
@ -116,4 +121,42 @@ export class CompaniesController {
|
||||||
const company = await this.companiesService.remove(user.tenantId!, id);
|
const company = await this.companiesService.remove(user.tenantId!, id);
|
||||||
return singleResponse(company);
|
return singleResponse(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Owner Endpoints
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post(':id/owners')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Owner zu Unternehmen hinzufuegen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async addOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AddOwnerDto,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.addCompanyOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/owners/:userId')
|
||||||
|
@ApiOperation({ summary: 'Owner von Unternehmen entfernen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||||
|
async removeOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.removeCompanyOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { CompaniesController } from './companies.controller';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||||
import { LexwareModule } from '../lexware/lexware.module';
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CrmPrismaModule, LexwareModule],
|
imports: [CrmPrismaModule, LexwareModule, OwnersModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService],
|
providers: [CompaniesService],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompaniesService {
|
export class CompaniesService {
|
||||||
|
|
@ -16,6 +17,46 @@ export class CompaniesService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||||
|
// Status aus DTO ableiten (status hat Vorrang vor isActive)
|
||||||
|
const status = dto.status
|
||||||
|
?? (dto.isActive === false ? EntityStatus.INACTIVE : EntityStatus.ACTIVE);
|
||||||
|
const isActive = status !== EntityStatus.INACTIVE && status !== EntityStatus.BLOCKED;
|
||||||
|
|
||||||
|
// Legacy email/phone: aus Multi-Value Primary ableiten oder aus DTO
|
||||||
|
const primaryEmail = dto.emails?.find((e) => e.isPrimary)?.email
|
||||||
|
?? dto.emails?.[0]?.email ?? dto.email;
|
||||||
|
const primaryPhone = dto.phones?.find((p) => p.isPrimary)?.phone
|
||||||
|
?? dto.phones?.find((p) => p.type === 'OFFICE')?.phone
|
||||||
|
?? dto.phones?.[0]?.phone ?? dto.phone;
|
||||||
|
|
||||||
|
// Multi-Value emails vorbereiten
|
||||||
|
const emailsCreate: Prisma.ContactEmailCreateWithoutCompanyInput[] = [];
|
||||||
|
if (dto.emails && dto.emails.length > 0) {
|
||||||
|
dto.emails.forEach((e) => {
|
||||||
|
emailsCreate.push({
|
||||||
|
email: e.email,
|
||||||
|
type: e.type ?? 'WORK',
|
||||||
|
isPrimary: e.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (dto.email) {
|
||||||
|
emailsCreate.push({ email: dto.email, type: 'WORK', isPrimary: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Value phones vorbereiten
|
||||||
|
const phonesCreate: Prisma.ContactPhoneCreateWithoutCompanyInput[] = [];
|
||||||
|
if (dto.phones && dto.phones.length > 0) {
|
||||||
|
dto.phones.forEach((p) => {
|
||||||
|
phonesCreate.push({
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type ?? 'OFFICE',
|
||||||
|
isPrimary: p.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (dto.phone) {
|
||||||
|
phonesCreate.push({ phone: dto.phone, type: 'OFFICE', isPrimary: true });
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.company.create({
|
return this.prisma.company.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -26,21 +67,39 @@ export class CompaniesService {
|
||||||
accountTypeId: dto.accountTypeId,
|
accountTypeId: dto.accountTypeId,
|
||||||
ownerId: dto.ownerId,
|
ownerId: dto.ownerId,
|
||||||
ownerName: dto.ownerName,
|
ownerName: dto.ownerName,
|
||||||
email: dto.email,
|
vatId: dto.vatId,
|
||||||
phone: dto.phone,
|
taxId: dto.taxId,
|
||||||
|
tradeRegisterNumber: dto.tradeRegisterNumber,
|
||||||
|
registerCourt: dto.registerCourt,
|
||||||
|
companySize: dto.companySize,
|
||||||
|
email: primaryEmail,
|
||||||
|
phone: primaryPhone,
|
||||||
website: dto.website,
|
website: dto.website,
|
||||||
street: dto.street,
|
street: dto.street,
|
||||||
zip: dto.zip,
|
zip: dto.zip,
|
||||||
city: dto.city,
|
city: dto.city,
|
||||||
state: dto.state,
|
state: dto.state,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
|
deliveryStreet: dto.deliveryStreet,
|
||||||
|
deliveryZip: dto.deliveryZip,
|
||||||
|
deliveryCity: dto.deliveryCity,
|
||||||
|
deliveryCountry: dto.deliveryCountry,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
isActive: dto.isActive ?? true,
|
status,
|
||||||
|
isActive,
|
||||||
|
emails: emailsCreate.length > 0 ? { create: emailsCreate } : undefined,
|
||||||
|
phones: phonesCreate.length > 0 ? { create: phonesCreate } : undefined,
|
||||||
|
owners: {
|
||||||
|
create: { tenantId, userId, role: 'OWNER' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
industryRef: true,
|
industryRef: true,
|
||||||
accountType: true,
|
accountType: true,
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
_count: { select: { contacts: true, deals: true } },
|
_count: { select: { contacts: true, deals: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -56,6 +115,10 @@ export class CompaniesService {
|
||||||
where.industry = { contains: query.industry, mode: 'insensitive' };
|
where.industry = { contains: query.industry, mode: 'insensitive' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
|
@ -85,6 +148,8 @@ export class CompaniesService {
|
||||||
include: {
|
include: {
|
||||||
industryRef: true,
|
industryRef: true,
|
||||||
accountType: true,
|
accountType: true,
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
_count: { select: { contacts: true, deals: true } },
|
_count: { select: { contacts: true, deals: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -100,8 +165,11 @@ export class CompaniesService {
|
||||||
include: {
|
include: {
|
||||||
industryRef: true,
|
industryRef: true,
|
||||||
accountType: true,
|
accountType: true,
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
contacts: {
|
contacts: {
|
||||||
where: { isActive: true },
|
where: { status: 'ACTIVE' },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -112,6 +180,7 @@ export class CompaniesService {
|
||||||
phone: true,
|
phone: true,
|
||||||
position: true,
|
position: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deals: {
|
deals: {
|
||||||
|
|
@ -169,18 +238,89 @@ export class CompaniesService {
|
||||||
) {
|
) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
const updated = await this.prisma.company.update({
|
// DTO-Felder aufteilen: Prisma-Scalar-Felder vs. nested Relations
|
||||||
where: { id },
|
const { emails, phones, isActive, status: dtoStatus, ...scalarFields } = dto;
|
||||||
data: {
|
|
||||||
...dto,
|
// Status ↔ isActive synchron halten
|
||||||
|
let status: EntityStatus | undefined;
|
||||||
|
let isActiveBool: boolean | undefined;
|
||||||
|
if (dtoStatus !== undefined) {
|
||||||
|
status = dtoStatus;
|
||||||
|
isActiveBool = dtoStatus !== EntityStatus.INACTIVE && dtoStatus !== EntityStatus.BLOCKED;
|
||||||
|
} else if (isActive !== undefined) {
|
||||||
|
isActiveBool = isActive;
|
||||||
|
status = isActive ? EntityStatus.ACTIVE : EntityStatus.INACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace-Strategie fuer emails/phones in Transaction
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Emails: Alle loeschen und neu anlegen
|
||||||
|
if (emails !== undefined) {
|
||||||
|
await tx.contactEmail.deleteMany({ where: { companyId: id } });
|
||||||
|
if (emails.length > 0) {
|
||||||
|
await tx.contactEmail.createMany({
|
||||||
|
data: emails.map((e) => ({
|
||||||
|
companyId: id,
|
||||||
|
email: e.email,
|
||||||
|
type: e.type ?? 'WORK',
|
||||||
|
isPrimary: e.isPrimary ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phones: Alle loeschen und neu anlegen
|
||||||
|
if (phones !== undefined) {
|
||||||
|
await tx.contactPhone.deleteMany({ where: { companyId: id } });
|
||||||
|
if (phones.length > 0) {
|
||||||
|
await tx.contactPhone.createMany({
|
||||||
|
data: phones.map((p) => ({
|
||||||
|
companyId: id,
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type ?? 'OFFICE',
|
||||||
|
isPrimary: p.isPrimary ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy-Felder aus Multi-Value aktualisieren
|
||||||
|
let legacyEmail: string | null | undefined;
|
||||||
|
let legacyPhone: string | null | undefined;
|
||||||
|
|
||||||
|
if (emails !== undefined) {
|
||||||
|
legacyEmail = emails.find((e) => e.isPrimary)?.email
|
||||||
|
?? emails[0]?.email ?? null;
|
||||||
|
}
|
||||||
|
if (phones !== undefined) {
|
||||||
|
legacyPhone = phones.find((p) => p.isPrimary)?.phone
|
||||||
|
?? phones.find((p) => p.type === 'OFFICE')?.phone
|
||||||
|
?? phones[0]?.phone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Prisma.CompanyUpdateInput = {
|
||||||
|
...scalarFields,
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (status !== undefined) updateData.status = status;
|
||||||
|
if (isActiveBool !== undefined) updateData.isActive = isActiveBool;
|
||||||
|
if (legacyEmail !== undefined) updateData.email = legacyEmail;
|
||||||
|
if (legacyPhone !== undefined) updateData.phone = legacyPhone;
|
||||||
|
|
||||||
|
return tx.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
industryRef: true,
|
industryRef: true,
|
||||||
accountType: true,
|
accountType: true,
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
_count: { select: { contacts: true, deals: true } },
|
_count: { select: { contacts: true, deals: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||||
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@ import {
|
||||||
IsUrl,
|
IsUrl,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { CreateEmailDto, CreatePhoneDto, EntityStatus, CompanySize } from '../../common/dto/contact-info.dto';
|
||||||
|
|
||||||
export class CreateCompanyDto {
|
export class CreateCompanyDto {
|
||||||
@ApiProperty({ maxLength: 200 })
|
@ApiProperty({ maxLength: 200 })
|
||||||
|
|
@ -43,6 +47,35 @@ export class CreateCompanyDto {
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 50, description: 'USt-IdNr.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
vatId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 50, description: 'Steuernummer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100, description: 'Handelsregisternummer (z.B. HRB 12345)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
tradeRegisterNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200, description: 'Registergericht' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
registerCourt?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanySize, description: 'Unternehmensgroesse' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CompanySize)
|
||||||
|
companySize?: CompanySize;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 255 })
|
@ApiPropertyOptional({ maxLength: 255 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
|
@ -91,6 +124,30 @@ export class CreateCompanyDto {
|
||||||
@MaxLength(2)
|
@MaxLength(2)
|
||||||
country?: string;
|
country?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200, description: 'Lieferadresse: Strasse' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
deliveryStreet?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20, description: 'Lieferadresse: PLZ' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
deliveryZip?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100, description: 'Lieferadresse: Stadt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
deliveryCity?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 2, description: 'Lieferadresse: Land (ISO 2-Zeichen)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2)
|
||||||
|
deliveryCountry?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -102,8 +159,27 @@ export class CreateCompanyDto {
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ enum: EntityStatus, default: EntityStatus.ACTIVE, description: 'Status (ersetzt isActive)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntityStatus)
|
||||||
|
status?: EntityStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true, deprecated: true, description: 'DEPRECATED: Bitte status verwenden' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreateEmailDto], description: 'E-Mail-Adressen (Multi-Value)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateEmailDto)
|
||||||
|
emails?: CreateEmailDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreatePhoneDto], description: 'Telefonnummern (Multi-Value)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePhoneDto)
|
||||||
|
phones?: CreatePhoneDto[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
|
import { IsOptional, IsString, IsInt, Min, Max, IsIn, IsEnum } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { EntityStatus } from '../../common/dto/contact-info.dto';
|
||||||
|
|
||||||
export class QueryCompaniesDto {
|
export class QueryCompaniesDto {
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
|
@ -28,6 +29,11 @@ export class QueryCompaniesDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
industry?: string;
|
industry?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: EntityStatus, description: 'Filter nach Status' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntityStatus)
|
||||||
|
status?: EntityStatus;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 'createdAt' })
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,4 @@
|
||||||
import {
|
import { PartialType } from '@nestjs/swagger';
|
||||||
IsString,
|
import { CreateCompanyDto } from './create-company.dto';
|
||||||
IsOptional,
|
|
||||||
IsBoolean,
|
|
||||||
IsEmail,
|
|
||||||
IsUrl,
|
|
||||||
IsUUID,
|
|
||||||
IsArray,
|
|
||||||
MaxLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class UpdateCompanyDto {
|
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||||
@ApiPropertyOptional({ maxLength: 200 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 100 })
|
|
||||||
@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()
|
|
||||||
@MaxLength(255)
|
|
||||||
email?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 50 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(50)
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 500 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@MaxLength(500)
|
|
||||||
website?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 200 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
street?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 20 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
zip?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 100 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
city?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 100 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
state?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(2)
|
|
||||||
country?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [String] })
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
tags?: string[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import { ContactsService } from './contacts.service';
|
||||||
import { CreateContactDto } from './dto/create-contact.dto';
|
import { CreateContactDto } from './dto/create-contact.dto';
|
||||||
import { UpdateContactDto } from './dto/update-contact.dto';
|
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
|
import { OwnersService } from '../owners/owners.service';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,7 +36,10 @@ import {
|
||||||
@UseGuards(TenantGuard)
|
@UseGuards(TenantGuard)
|
||||||
@Controller('contacts')
|
@Controller('contacts')
|
||||||
export class ContactsController {
|
export class ContactsController {
|
||||||
constructor(private readonly contactsService: ContactsService) {}
|
constructor(
|
||||||
|
private readonly contactsService: ContactsService,
|
||||||
|
private readonly ownersService: OwnersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
|
@ -104,4 +109,42 @@ export class ContactsController {
|
||||||
const contact = await this.contactsService.remove(user.tenantId!, id);
|
const contact = await this.contactsService.remove(user.tenantId!, id);
|
||||||
return singleResponse(contact);
|
return singleResponse(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Owner Endpoints
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post(':id/owners')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Owner zu Kontakt hinzufuegen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async addOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AddOwnerDto,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.addContactOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/owners/:userId')
|
||||||
|
@ApiOperation({ summary: 'Owner von Kontakt entfernen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||||
|
async removeOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.removeContactOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||||
import { ContactsController } from './contacts.controller';
|
import { ContactsController } from './contacts.controller';
|
||||||
import { ContactsService } from './contacts.service';
|
import { ContactsService } from './contacts.service';
|
||||||
import { LexwareModule } from '../lexware/lexware.module';
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LexwareModule],
|
imports: [LexwareModule, OwnersModule],
|
||||||
controllers: [ContactsController],
|
controllers: [ContactsController],
|
||||||
providers: [ContactsService],
|
providers: [ContactsService],
|
||||||
exports: [ContactsService],
|
exports: [ContactsService],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import { CreateContactDto } from './dto/create-contact.dto';
|
||||||
import { UpdateContactDto } from './dto/update-contact.dto';
|
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
|
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContactsService {
|
export class ContactsService {
|
||||||
|
|
@ -13,10 +15,58 @@ export class ContactsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly lexwareContacts: LexwareContactsService,
|
private readonly lexwareContacts: LexwareContactsService,
|
||||||
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||||
return this.prisma.contact.create({
|
// Status aus DTO ableiten (status hat Vorrang vor isActive)
|
||||||
|
const status = dto.status
|
||||||
|
?? (dto.isActive === false ? EntityStatus.INACTIVE : EntityStatus.ACTIVE);
|
||||||
|
const isActive = status !== EntityStatus.INACTIVE && status !== EntityStatus.BLOCKED;
|
||||||
|
|
||||||
|
// Legacy email/phone/mobile: aus Multi-Value Primary ableiten oder aus DTO
|
||||||
|
const primaryEmail = dto.emails?.find((e) => e.isPrimary)?.email
|
||||||
|
?? dto.emails?.[0]?.email ?? dto.email;
|
||||||
|
const primaryPhone = dto.phones?.find((p) => p.isPrimary)?.phone
|
||||||
|
?? dto.phones?.find((p) => p.type === 'OFFICE')?.phone
|
||||||
|
?? dto.phones?.[0]?.phone ?? dto.phone;
|
||||||
|
const primaryMobile = dto.phones?.find((p) => p.type === 'MOBILE')?.phone
|
||||||
|
?? dto.mobile;
|
||||||
|
|
||||||
|
// Multi-Value emails vorbereiten
|
||||||
|
const emailsCreate: Prisma.ContactEmailCreateWithoutContactInput[] = [];
|
||||||
|
if (dto.emails && dto.emails.length > 0) {
|
||||||
|
dto.emails.forEach((e) => {
|
||||||
|
emailsCreate.push({
|
||||||
|
email: e.email,
|
||||||
|
type: e.type ?? 'WORK',
|
||||||
|
isPrimary: e.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (dto.email) {
|
||||||
|
emailsCreate.push({ email: dto.email, type: 'WORK', isPrimary: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Value phones vorbereiten
|
||||||
|
const phonesCreate: Prisma.ContactPhoneCreateWithoutContactInput[] = [];
|
||||||
|
if (dto.phones && dto.phones.length > 0) {
|
||||||
|
dto.phones.forEach((p) => {
|
||||||
|
phonesCreate.push({
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type ?? 'OFFICE',
|
||||||
|
isPrimary: p.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (dto.phone) {
|
||||||
|
phonesCreate.push({ phone: dto.phone, type: 'OFFICE', isPrimary: true });
|
||||||
|
}
|
||||||
|
if (dto.mobile) {
|
||||||
|
phonesCreate.push({ phone: dto.mobile, type: 'MOBILE', isPrimary: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await this.prisma.contact.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
|
|
@ -26,10 +76,14 @@ export class ContactsService {
|
||||||
companyId: dto.companyId,
|
companyId: dto.companyId,
|
||||||
companyName: dto.companyName,
|
companyName: dto.companyName,
|
||||||
position: dto.position,
|
position: dto.position,
|
||||||
email: dto.email,
|
email: primaryEmail,
|
||||||
phone: dto.phone,
|
phone: primaryPhone,
|
||||||
mobile: dto.mobile,
|
mobile: primaryMobile,
|
||||||
website: dto.website,
|
website: dto.website,
|
||||||
|
linkedinUrl: dto.linkedinUrl,
|
||||||
|
birthday: dto.birthday ? new Date(dto.birthday) : undefined,
|
||||||
|
source: dto.source,
|
||||||
|
department: dto.department,
|
||||||
street: dto.street,
|
street: dto.street,
|
||||||
zip: dto.zip,
|
zip: dto.zip,
|
||||||
city: dto.city,
|
city: dto.city,
|
||||||
|
|
@ -37,9 +91,26 @@ export class ContactsService {
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
isActive: dto.isActive ?? true,
|
status,
|
||||||
|
isActive,
|
||||||
|
emails: emailsCreate.length > 0 ? { create: emailsCreate } : undefined,
|
||||||
|
phones: phonesCreate.length > 0 ? { create: phonesCreate } : undefined,
|
||||||
|
owners: {
|
||||||
|
create: { tenantId, userId, role: 'OWNER' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
|
company: { select: { id: true, name: true, industry: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Event publizieren (async, nicht blockierend)
|
||||||
|
this.eventPublisher.contactCreated(tenantId, contact.id, userId).catch(() => {});
|
||||||
|
|
||||||
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, query: QueryContactsDto) {
|
async findAll(tenantId: string, query: QueryContactsDto) {
|
||||||
|
|
@ -56,6 +127,10 @@ export class ContactsService {
|
||||||
where.companyId = query.companyId;
|
where.companyId = query.companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
|
@ -85,6 +160,8 @@ export class ContactsService {
|
||||||
orderBy: { [sortField]: query.order ?? 'desc' },
|
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||||
include: {
|
include: {
|
||||||
company: { select: { id: true, name: true, industry: true } },
|
company: { select: { id: true, name: true, industry: true } },
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.contact.count({ where }),
|
this.prisma.contact.count({ where }),
|
||||||
|
|
@ -99,6 +176,9 @@ export class ContactsService {
|
||||||
include: {
|
include: {
|
||||||
company: { select: { id: true, name: true, industry: true, city: true, website: true } },
|
company: { select: { id: true, name: true, industry: true, city: true, website: true } },
|
||||||
activities: { orderBy: { createdAt: 'desc' }, take: 10 },
|
activities: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -117,13 +197,96 @@ export class ContactsService {
|
||||||
) {
|
) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
const updated = await this.prisma.contact.update({
|
// DTO-Felder aufteilen: Prisma-Scalar-Felder vs. nested Relations
|
||||||
where: { id },
|
const { emails, phones, isActive, status: dtoStatus, birthday, ...scalarFields } = dto;
|
||||||
data: {
|
|
||||||
...dto,
|
// Status ↔ isActive synchron halten
|
||||||
|
let status: EntityStatus | undefined;
|
||||||
|
let isActiveBool: boolean | undefined;
|
||||||
|
if (dtoStatus !== undefined) {
|
||||||
|
status = dtoStatus;
|
||||||
|
isActiveBool = dtoStatus !== EntityStatus.INACTIVE && dtoStatus !== EntityStatus.BLOCKED;
|
||||||
|
} else if (isActive !== undefined) {
|
||||||
|
isActiveBool = isActive;
|
||||||
|
status = isActive ? EntityStatus.ACTIVE : EntityStatus.INACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace-Strategie fuer emails/phones in Transaction
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Emails: Alle loeschen und neu anlegen
|
||||||
|
if (emails !== undefined) {
|
||||||
|
await tx.contactEmail.deleteMany({ where: { contactId: id } });
|
||||||
|
if (emails.length > 0) {
|
||||||
|
await tx.contactEmail.createMany({
|
||||||
|
data: emails.map((e) => ({
|
||||||
|
contactId: id,
|
||||||
|
email: e.email,
|
||||||
|
type: e.type ?? 'WORK',
|
||||||
|
isPrimary: e.isPrimary ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phones: Alle loeschen und neu anlegen
|
||||||
|
if (phones !== undefined) {
|
||||||
|
await tx.contactPhone.deleteMany({ where: { contactId: id } });
|
||||||
|
if (phones.length > 0) {
|
||||||
|
await tx.contactPhone.createMany({
|
||||||
|
data: phones.map((p) => ({
|
||||||
|
contactId: id,
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type ?? 'OFFICE',
|
||||||
|
isPrimary: p.isPrimary ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy-Felder aus Multi-Value aktualisieren
|
||||||
|
let legacyEmail: string | null | undefined;
|
||||||
|
let legacyPhone: string | null | undefined;
|
||||||
|
let legacyMobile: string | null | undefined;
|
||||||
|
|
||||||
|
if (emails !== undefined) {
|
||||||
|
legacyEmail = emails.find((e) => e.isPrimary)?.email
|
||||||
|
?? emails[0]?.email ?? null;
|
||||||
|
}
|
||||||
|
if (phones !== undefined) {
|
||||||
|
legacyPhone = phones.find((p) => p.isPrimary)?.phone
|
||||||
|
?? phones.find((p) => p.type === 'OFFICE')?.phone
|
||||||
|
?? phones[0]?.phone ?? null;
|
||||||
|
legacyMobile = phones.find((p) => p.type === 'MOBILE')?.phone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Prisma.ContactUpdateInput = {
|
||||||
|
...scalarFields,
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (birthday !== undefined) {
|
||||||
|
updateData.birthday = birthday ? new Date(birthday) : null;
|
||||||
|
}
|
||||||
|
if (status !== undefined) updateData.status = status;
|
||||||
|
if (isActiveBool !== undefined) updateData.isActive = isActiveBool;
|
||||||
|
if (legacyEmail !== undefined) updateData.email = legacyEmail;
|
||||||
|
if (legacyPhone !== undefined) updateData.phone = legacyPhone;
|
||||||
|
if (legacyMobile !== undefined) updateData.mobile = legacyMobile;
|
||||||
|
|
||||||
|
return tx.contact.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
|
company: { select: { id: true, name: true, industry: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event publizieren (async, nicht blockierend)
|
||||||
|
this.eventPublisher.contactUpdated(tenantId, id, userId).catch(() => {});
|
||||||
|
|
||||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||||
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ import {
|
||||||
MaxLength,
|
MaxLength,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
|
IsDateString,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
CreateEmailDto,
|
||||||
|
CreatePhoneDto,
|
||||||
|
ContactSource,
|
||||||
|
EntityStatus,
|
||||||
|
} from '../../common/dto/contact-info.dto';
|
||||||
|
|
||||||
export enum ContactType {
|
export enum ContactType {
|
||||||
PERSON = 'PERSON',
|
PERSON = 'PERSON',
|
||||||
|
|
@ -75,6 +84,28 @@ export class CreateContactDto {
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
website?: string;
|
website?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 500, description: 'LinkedIn-Profil-URL' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
@MaxLength(500)
|
||||||
|
linkedinUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Geburtsdatum (ISO 8601)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
birthday?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ContactSource, description: 'Herkunft/Quelle des Kontakts' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContactSource)
|
||||||
|
source?: ContactSource;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200, description: 'Abteilung' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
department?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 200 })
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -116,8 +147,27 @@ export class CreateContactDto {
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ enum: EntityStatus, default: EntityStatus.ACTIVE, description: 'Status (ersetzt isActive)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntityStatus)
|
||||||
|
status?: EntityStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true, deprecated: true, description: 'DEPRECATED: Bitte status verwenden' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreateEmailDto], description: 'E-Mail-Adressen (Multi-Value)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateEmailDto)
|
||||||
|
emails?: CreateEmailDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreatePhoneDto], description: 'Telefonnummern (Multi-Value)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePhoneDto)
|
||||||
|
phones?: CreatePhoneDto[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
import { ContactType } from './create-contact.dto';
|
import { ContactType } from './create-contact.dto';
|
||||||
|
import { EntityStatus } from '../../common/dto/contact-info.dto';
|
||||||
|
|
||||||
export class QueryContactsDto extends PaginationDto {
|
export class QueryContactsDto extends PaginationDto {
|
||||||
@ApiPropertyOptional({ description: 'Suchbegriff (Name, Firma, E-Mail)' })
|
@ApiPropertyOptional({ description: 'Suchbegriff (Name, Firma, E-Mail)' })
|
||||||
|
|
@ -19,6 +20,11 @@ export class QueryContactsDto extends PaginationDto {
|
||||||
@IsEnum(ContactType)
|
@IsEnum(ContactType)
|
||||||
type?: ContactType;
|
type?: ContactType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: EntityStatus, description: 'Filter nach Status' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntityStatus)
|
||||||
|
status?: EntityStatus;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sortierfeld', default: 'createdAt' })
|
@ApiPropertyOptional({ description: 'Sortierfeld', default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import { DealsService } from './deals.service';
|
||||||
import { CreateDealDto } from './dto/create-deal.dto';
|
import { CreateDealDto } from './dto/create-deal.dto';
|
||||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||||
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
|
import { OwnersService } from '../owners/owners.service';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,7 +36,10 @@ import {
|
||||||
@UseGuards(TenantGuard)
|
@UseGuards(TenantGuard)
|
||||||
@Controller('deals')
|
@Controller('deals')
|
||||||
export class DealsController {
|
export class DealsController {
|
||||||
constructor(private readonly dealsService: DealsService) {}
|
constructor(
|
||||||
|
private readonly dealsService: DealsService,
|
||||||
|
private readonly ownersService: OwnersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
|
@ -104,4 +109,42 @@ export class DealsController {
|
||||||
const deal = await this.dealsService.remove(user.tenantId!, id);
|
const deal = await this.dealsService.remove(user.tenantId!, id);
|
||||||
return singleResponse(deal);
|
return singleResponse(deal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Owner Endpoints
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post(':id/owners')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Owner zu Vorgang hinzufuegen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async addOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AddOwnerDto,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.addDealOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/owners/:userId')
|
||||||
|
@ApiOperation({ summary: 'Owner von Vorgang entfernen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||||
|
async removeOwner(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
) {
|
||||||
|
const owner = await this.ownersService.removeDealOwner(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return singleResponse(owner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DealsController } from './deals.controller';
|
import { DealsController } from './deals.controller';
|
||||||
import { DealsService } from './deals.service';
|
import { DealsService } from './deals.service';
|
||||||
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [OwnersModule],
|
||||||
controllers: [DealsController],
|
controllers: [DealsController],
|
||||||
providers: [DealsService],
|
providers: [DealsService],
|
||||||
exports: [DealsService],
|
exports: [DealsService],
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { CreateDealDto } from './dto/create-deal.dto';
|
import { CreateDealDto } from './dto/create-deal.dto';
|
||||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||||
|
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DealsService {
|
export class DealsService {
|
||||||
constructor(private readonly prisma: CrmPrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: CrmPrismaService,
|
||||||
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||||
// Pipeline und Stage validieren
|
// Pipeline und Stage validieren
|
||||||
|
|
@ -45,7 +53,7 @@ export class DealsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.deal.create({
|
const deal = await this.prisma.deal.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
pipelineId: dto.pipelineId,
|
pipelineId: dto.pipelineId,
|
||||||
|
|
@ -60,7 +68,12 @@ export class DealsService {
|
||||||
? new Date(dto.expectedCloseDate)
|
? new Date(dto.expectedCloseDate)
|
||||||
: undefined,
|
: undefined,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
|
lostReason: dto.lostReason,
|
||||||
|
lostReasonText: dto.lostReasonText,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
|
owners: {
|
||||||
|
create: { tenantId, userId, role: 'OWNER' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
pipeline: { select: { id: true, name: true } },
|
pipeline: { select: { id: true, name: true } },
|
||||||
|
|
@ -79,8 +92,14 @@ export class DealsService {
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
owners: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Event publizieren (async, nicht blockierend)
|
||||||
|
this.eventPublisher.dealCreated(tenantId, deal.id, userId).catch(() => {});
|
||||||
|
|
||||||
|
return deal;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, query: QueryDealsDto) {
|
async findAll(tenantId: string, query: QueryDealsDto) {
|
||||||
|
|
@ -142,6 +161,7 @@ export class DealsService {
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
owners: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.deal.count({ where }),
|
this.prisma.deal.count({ where }),
|
||||||
|
|
@ -158,6 +178,7 @@ export class DealsService {
|
||||||
stage: true,
|
stage: true,
|
||||||
contact: true,
|
contact: true,
|
||||||
company: true,
|
company: true,
|
||||||
|
owners: true,
|
||||||
dealVouchers: {
|
dealVouchers: {
|
||||||
include: {
|
include: {
|
||||||
voucher: {
|
voucher: {
|
||||||
|
|
@ -192,12 +213,11 @@ export class DealsService {
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: UpdateDealDto,
|
dto: UpdateDealDto,
|
||||||
) {
|
) {
|
||||||
await this.findOne(tenantId, id);
|
const existing = await this.findOne(tenantId, id);
|
||||||
|
|
||||||
// Stage validieren wenn geaendert
|
// Stage validieren wenn geaendert
|
||||||
if (dto.stageId) {
|
if (dto.stageId) {
|
||||||
const deal = await this.prisma.deal.findUnique({ where: { id } });
|
const pipelineId = dto.pipelineId ?? existing.pipelineId;
|
||||||
const pipelineId = dto.pipelineId ?? deal?.pipelineId;
|
|
||||||
const stage = await this.prisma.pipelineStage.findFirst({
|
const stage = await this.prisma.pipelineStage.findFirst({
|
||||||
where: { id: dto.stageId, pipelineId },
|
where: { id: dto.stageId, pipelineId },
|
||||||
});
|
});
|
||||||
|
|
@ -206,20 +226,46 @@ export class DealsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lost-Reason Validation
|
||||||
|
if (dto.status === 'LOST') {
|
||||||
|
// Wenn LOST gesetzt wird, muss lostReason vorhanden sein (im DTO oder bereits gesetzt)
|
||||||
|
if (!dto.lostReason && !existing.lostReason) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Verlustgrund (lostReason) ist Pflicht wenn Deal auf LOST gesetzt wird',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lostReason, lostReasonText, ...restDto } = dto;
|
||||||
|
|
||||||
const updateData: Prisma.DealUpdateInput = {
|
const updateData: Prisma.DealUpdateInput = {
|
||||||
...dto,
|
...restDto,
|
||||||
expectedCloseDate: dto.expectedCloseDate
|
expectedCloseDate: dto.expectedCloseDate
|
||||||
? new Date(dto.expectedCloseDate)
|
? new Date(dto.expectedCloseDate)
|
||||||
: undefined,
|
: undefined,
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lost-Reason Felder setzen
|
||||||
|
if (lostReason !== undefined) {
|
||||||
|
updateData.lostReason = lostReason;
|
||||||
|
}
|
||||||
|
if (lostReasonText !== undefined) {
|
||||||
|
updateData.lostReasonText = lostReasonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn Deal gewonnen → lostReason und lostReasonText loeschen
|
||||||
|
if (dto.status === 'WON') {
|
||||||
|
updateData.lostReason = null;
|
||||||
|
updateData.lostReasonText = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Wenn Deal gewonnen/verloren, closedAt setzen
|
// Wenn Deal gewonnen/verloren, closedAt setzen
|
||||||
if (dto.status === 'WON' || dto.status === 'LOST') {
|
if (dto.status === 'WON' || dto.status === 'LOST') {
|
||||||
updateData.closedAt = new Date();
|
updateData.closedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.deal.update({
|
const updated = await this.prisma.deal.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -233,8 +279,38 @@ export class DealsService {
|
||||||
companyName: true,
|
companyName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
company: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
owners: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Events publizieren (async, nicht blockierend)
|
||||||
|
if (dto.stageId && dto.stageId !== existing.stageId) {
|
||||||
|
this.eventPublisher.dealStageChanged(tenantId, id, userId, {
|
||||||
|
previousStageId: existing.stageId,
|
||||||
|
newStageId: dto.stageId,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status === 'WON') {
|
||||||
|
this.eventPublisher.dealWon(tenantId, id, userId, {
|
||||||
|
value: updated.value?.toNumber() ?? null,
|
||||||
|
currency: updated.currency ?? 'EUR',
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status === 'LOST') {
|
||||||
|
this.eventPublisher.dealLost(tenantId, id, userId, {
|
||||||
|
lostReason: updated.lostReason ?? null,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(tenantId: string, id: string) {
|
async remove(tenantId: string, id: string) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ export enum DealStatus {
|
||||||
LOST = 'LOST',
|
LOST = 'LOST',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum LostReason {
|
||||||
|
PRICE = 'PRICE',
|
||||||
|
TIMING = 'TIMING',
|
||||||
|
COMPETITOR = 'COMPETITOR',
|
||||||
|
NO_NEED = 'NO_NEED',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateDealDto {
|
export class CreateDealDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|
@ -66,4 +74,14 @@ export class CreateDealDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: LostReason, description: 'Verlustgrund (Pflicht bei Status LOST)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LostReason)
|
||||||
|
lostReason?: LostReason;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Freitext-Begruendung zum Verlustgrund' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lostReasonText?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
// ============================================================
|
||||||
|
// Scheduler: Activity Due Soon - alle 15 Minuten
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { CrmEventPublisher } from './crm-event-publisher.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityDueSoonScheduler {
|
||||||
|
private readonly logger = new Logger(ActivityDueSoonScheduler.name);
|
||||||
|
private static readonly LOCK_KEY = 'lock:activity-due-soon';
|
||||||
|
private static readonly LOCK_TTL = 600; // 10 Minuten
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: CrmPrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('0 */15 * * * *')
|
||||||
|
async checkDueSoon(): Promise<void> {
|
||||||
|
// Distributed Lock um parallele Ausfuehrung zu verhindern
|
||||||
|
const lockAcquired = await this.redis.setNx(
|
||||||
|
ActivityDueSoonScheduler.LOCK_KEY,
|
||||||
|
process.pid.toString(),
|
||||||
|
ActivityDueSoonScheduler.LOCK_TTL,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lockAcquired) {
|
||||||
|
this.logger.debug('Activity Due Soon Check: Lock bereits vergeben, ueberspringe.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Activities die in den naechsten 24h faellig sind und nicht completed
|
||||||
|
const dueActivities = await this.prisma.activity.findMany({
|
||||||
|
where: {
|
||||||
|
scheduledAt: {
|
||||||
|
gte: now,
|
||||||
|
lte: in24h,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
scheduledAt: true,
|
||||||
|
contactId: true,
|
||||||
|
companyId: true,
|
||||||
|
},
|
||||||
|
take: 100, // Batch-Limit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dueActivities.length === 0) {
|
||||||
|
this.logger.debug('Keine faelligen Activities gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`${dueActivities.length} faellige Activities gefunden, publiziere Events...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const activity of dueActivities) {
|
||||||
|
await this.eventPublisher.activityDueSoon(
|
||||||
|
activity.tenantId,
|
||||||
|
activity.id,
|
||||||
|
{
|
||||||
|
scheduledAt: activity.scheduledAt?.toISOString() ?? '',
|
||||||
|
contactId: activity.contactId ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`${dueActivities.length} activity.due_soon Events publiziert.`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Fehler im Activity Due Soon Scheduler: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
packages/crm-service/src/events/crm-event-publisher.service.ts
Normal file
184
packages/crm-service/src/events/crm-event-publisher.service.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// ============================================================
|
||||||
|
// CRM Event Publisher - Redis Pub/Sub
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
|
export interface CrmEvent {
|
||||||
|
type: string;
|
||||||
|
tenantId: string;
|
||||||
|
entityId: string;
|
||||||
|
userId: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CrmEventPublisher {
|
||||||
|
private readonly logger = new Logger(CrmEventPublisher.name);
|
||||||
|
|
||||||
|
constructor(private readonly redis: RedisService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Publish-Methode.
|
||||||
|
* Channel-Format: app:crm:events:{type}
|
||||||
|
*/
|
||||||
|
private async publish(event: CrmEvent): Promise<void> {
|
||||||
|
const channel = `app:crm:events:${event.type}`;
|
||||||
|
try {
|
||||||
|
const receivers = await this.redis.publish(channel, event as unknown as Record<string, unknown>);
|
||||||
|
this.logger.debug(
|
||||||
|
`Event "${event.type}" publiziert auf "${channel}" (${receivers} Empfaenger)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Fehler beim Publizieren von Event "${event.type}": ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Contact Events
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async contactCreated(tenantId: string, contactId: string, userId: string): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.contact.created',
|
||||||
|
tenantId,
|
||||||
|
entityId: contactId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async contactUpdated(
|
||||||
|
tenantId: string,
|
||||||
|
contactId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.contact.updated',
|
||||||
|
tenantId,
|
||||||
|
entityId: contactId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Company Events
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async companyCreated(tenantId: string, companyId: string, userId: string): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.company.created',
|
||||||
|
tenantId,
|
||||||
|
entityId: companyId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async companyUpdated(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.company.updated',
|
||||||
|
tenantId,
|
||||||
|
entityId: companyId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Deal Events
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async dealCreated(tenantId: string, dealId: string, userId: string): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.deal.created',
|
||||||
|
tenantId,
|
||||||
|
entityId: dealId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dealStageChanged(
|
||||||
|
tenantId: string,
|
||||||
|
dealId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: { previousStageId: string; newStageId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.deal.stage_changed',
|
||||||
|
tenantId,
|
||||||
|
entityId: dealId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dealWon(
|
||||||
|
tenantId: string,
|
||||||
|
dealId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: { value?: number | null; currency?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.deal.won',
|
||||||
|
tenantId,
|
||||||
|
entityId: dealId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dealLost(
|
||||||
|
tenantId: string,
|
||||||
|
dealId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: { lostReason?: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.deal.lost',
|
||||||
|
tenantId,
|
||||||
|
entityId: dealId,
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Activity Events
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async activityDueSoon(
|
||||||
|
tenantId: string,
|
||||||
|
activityId: string,
|
||||||
|
payload: { scheduledAt: string; contactId?: string; dealId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.publish({
|
||||||
|
type: 'crm.activity.due_soon',
|
||||||
|
tenantId,
|
||||||
|
entityId: activityId,
|
||||||
|
userId: 'system',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/events/crm-events.module.ts
Normal file
10
packages/crm-service/src/events/crm-events.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { CrmEventPublisher } from './crm-event-publisher.service';
|
||||||
|
import { ActivityDueSoonScheduler } from './activity-due-soon.scheduler';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [CrmEventPublisher, ActivityDueSoonScheduler],
|
||||||
|
exports: [CrmEventPublisher],
|
||||||
|
})
|
||||||
|
export class CrmEventsModule {}
|
||||||
|
|
@ -232,17 +232,38 @@ export class LexwareContactsService {
|
||||||
|
|
||||||
const lexwareContact = await this.getContact(lexwareContactId);
|
const lexwareContact = await this.getContact(lexwareContactId);
|
||||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||||
|
const { emails, phones, ...scalarData } = companyData;
|
||||||
|
|
||||||
return this.prisma.company.create({
|
return this.prisma.company.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
...companyData,
|
...scalarData,
|
||||||
lexwareContactId,
|
lexwareContactId,
|
||||||
lexwareContactVersion: lexwareContact.version,
|
lexwareContactVersion: lexwareContact.version,
|
||||||
lexwareSyncedAt: new Date(),
|
lexwareSyncedAt: new Date(),
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
|
emails: emails.length > 0 ? {
|
||||||
|
create: emails.map((e) => ({
|
||||||
|
email: e.email,
|
||||||
|
type: e.type,
|
||||||
|
isPrimary: e.isPrimary,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
phones: phones.length > 0 ? {
|
||||||
|
create: phones.map((p) => ({
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type,
|
||||||
|
isPrimary: p.isPrimary,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
owners: {
|
||||||
|
create: { tenantId, userId, role: 'OWNER' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
_count: { select: { contacts: true, deals: true } },
|
_count: { select: { contacts: true, deals: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -270,15 +291,38 @@ export class LexwareContactsService {
|
||||||
|
|
||||||
const lexwareContact = await this.getContact(lexwareContactId);
|
const lexwareContact = await this.getContact(lexwareContactId);
|
||||||
const contactData = lexwareContactToContactData(lexwareContact);
|
const contactData = lexwareContactToContactData(lexwareContact);
|
||||||
|
const { emails, phones, ...scalarData } = contactData;
|
||||||
|
|
||||||
return this.prisma.contact.create({
|
return this.prisma.contact.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
...contactData,
|
...scalarData,
|
||||||
lexwareContactId,
|
lexwareContactId,
|
||||||
lexwareContactVersion: lexwareContact.version,
|
lexwareContactVersion: lexwareContact.version,
|
||||||
lexwareSyncedAt: new Date(),
|
lexwareSyncedAt: new Date(),
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
|
emails: emails.length > 0 ? {
|
||||||
|
create: emails.map((e) => ({
|
||||||
|
email: e.email,
|
||||||
|
type: e.type,
|
||||||
|
isPrimary: e.isPrimary,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
phones: phones.length > 0 ? {
|
||||||
|
create: phones.map((p) => ({
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type,
|
||||||
|
isPrimary: p.isPrimary,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
owners: {
|
||||||
|
create: { tenantId, userId, role: 'OWNER' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
owners: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +334,7 @@ export class LexwareContactsService {
|
||||||
async pushCompanyToLexware(tenantId: string, companyId: string) {
|
async pushCompanyToLexware(tenantId: string, companyId: string) {
|
||||||
const company = await this.prisma.company.findFirst({
|
const company = await this.prisma.company.findFirst({
|
||||||
where: { id: companyId, tenantId },
|
where: { id: companyId, tenantId },
|
||||||
|
include: { emails: true, phones: true },
|
||||||
});
|
});
|
||||||
if (!company) {
|
if (!company) {
|
||||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||||
|
|
@ -347,6 +392,7 @@ export class LexwareContactsService {
|
||||||
async pushContactToLexware(tenantId: string, contactId: string) {
|
async pushContactToLexware(tenantId: string, contactId: string) {
|
||||||
const contact = await this.prisma.contact.findFirst({
|
const contact = await this.prisma.contact.findFirst({
|
||||||
where: { id: contactId, tenantId },
|
where: { id: contactId, tenantId },
|
||||||
|
include: { emails: true, phones: true },
|
||||||
});
|
});
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
throw new NotFoundException('Kontakt nicht gefunden');
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
|
@ -422,15 +468,49 @@ export class LexwareContactsService {
|
||||||
|
|
||||||
const lexwareContact = await this.getContact(company.lexwareContactId);
|
const lexwareContact = await this.getContact(company.lexwareContactId);
|
||||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||||
|
const { emails, phones, ...scalarData } = companyData;
|
||||||
|
|
||||||
return this.prisma.company.update({
|
// Transaction: Scalar-Update + emails/phones Replace
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Bestehende emails/phones loeschen
|
||||||
|
await tx.contactEmail.deleteMany({ where: { companyId } });
|
||||||
|
await tx.contactPhone.deleteMany({ where: { companyId } });
|
||||||
|
|
||||||
|
// Neue emails/phones anlegen
|
||||||
|
if (emails.length > 0) {
|
||||||
|
await tx.contactEmail.createMany({
|
||||||
|
data: emails.map((e) => ({
|
||||||
|
companyId,
|
||||||
|
email: e.email,
|
||||||
|
type: e.type,
|
||||||
|
isPrimary: e.isPrimary,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (phones.length > 0) {
|
||||||
|
await tx.contactPhone.createMany({
|
||||||
|
data: phones.map((p) => ({
|
||||||
|
companyId,
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type,
|
||||||
|
isPrimary: p.isPrimary,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.company.update({
|
||||||
where: { id: companyId },
|
where: { id: companyId },
|
||||||
data: {
|
data: {
|
||||||
...companyData,
|
...scalarData,
|
||||||
lexwareContactVersion: lexwareContact.version,
|
lexwareContactVersion: lexwareContact.version,
|
||||||
lexwareSyncedAt: new Date(),
|
lexwareSyncedAt: new Date(),
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,15 +531,49 @@ export class LexwareContactsService {
|
||||||
|
|
||||||
const lexwareContact = await this.getContact(contact.lexwareContactId);
|
const lexwareContact = await this.getContact(contact.lexwareContactId);
|
||||||
const contactData = lexwareContactToContactData(lexwareContact);
|
const contactData = lexwareContactToContactData(lexwareContact);
|
||||||
|
const { emails, phones, ...scalarData } = contactData;
|
||||||
|
|
||||||
return this.prisma.contact.update({
|
// Transaction: Scalar-Update + emails/phones Replace
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Bestehende emails/phones loeschen
|
||||||
|
await tx.contactEmail.deleteMany({ where: { contactId } });
|
||||||
|
await tx.contactPhone.deleteMany({ where: { contactId } });
|
||||||
|
|
||||||
|
// Neue emails/phones anlegen
|
||||||
|
if (emails.length > 0) {
|
||||||
|
await tx.contactEmail.createMany({
|
||||||
|
data: emails.map((e) => ({
|
||||||
|
contactId,
|
||||||
|
email: e.email,
|
||||||
|
type: e.type,
|
||||||
|
isPrimary: e.isPrimary,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (phones.length > 0) {
|
||||||
|
await tx.contactPhone.createMany({
|
||||||
|
data: phones.map((p) => ({
|
||||||
|
contactId,
|
||||||
|
phone: p.phone,
|
||||||
|
type: p.type,
|
||||||
|
isPrimary: p.isPrimary,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.contact.update({
|
||||||
where: { id: contactId },
|
where: { id: contactId },
|
||||||
data: {
|
data: {
|
||||||
...contactData,
|
...scalarData,
|
||||||
lexwareContactVersion: lexwareContact.version,
|
lexwareContactVersion: lexwareContact.version,
|
||||||
lexwareSyncedAt: new Date(),
|
lexwareSyncedAt: new Date(),
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
emails: true,
|
||||||
|
phones: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,21 @@ import {
|
||||||
LexwareVoucherListItem,
|
LexwareVoucherListItem,
|
||||||
} from '../interfaces/lexware-api.interfaces';
|
} from '../interfaces/lexware-api.interfaces';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Typen fuer Multi-Value Email/Phone Arrays
|
||||||
|
// --------------------------------------------------------
|
||||||
|
interface EmailEntry {
|
||||||
|
email: string;
|
||||||
|
type: 'WORK' | 'PERSONAL' | 'OTHER';
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhoneEntry {
|
||||||
|
phone: string;
|
||||||
|
type: 'OFFICE' | 'MOBILE' | 'FAX';
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Lexware Contact -> CRM Company
|
// Lexware Contact -> CRM Company
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
@ -22,6 +37,8 @@ export function lexwareContactToCompanyData(lc: LexwareContact): {
|
||||||
city?: string;
|
city?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
emails: EmailEntry[];
|
||||||
|
phones: PhoneEntry[];
|
||||||
} {
|
} {
|
||||||
const name =
|
const name =
|
||||||
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
|
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
|
||||||
|
|
@ -39,6 +56,8 @@ export function lexwareContactToCompanyData(lc: LexwareContact): {
|
||||||
city: billingAddr?.city || undefined,
|
city: billingAddr?.city || undefined,
|
||||||
country: billingAddr?.countryCode || 'DE',
|
country: billingAddr?.countryCode || 'DE',
|
||||||
notes: lc.note || undefined,
|
notes: lc.note || undefined,
|
||||||
|
emails: extractAllEmails(lc),
|
||||||
|
phones: extractAllPhones(lc),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,12 +78,16 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
||||||
country?: string;
|
country?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
type: 'PERSON' | 'ORGANIZATION';
|
type: 'PERSON' | 'ORGANIZATION';
|
||||||
|
emails: EmailEntry[];
|
||||||
|
phones: PhoneEntry[];
|
||||||
} {
|
} {
|
||||||
const isPerson = !!lc.person;
|
const isPerson = !!lc.person;
|
||||||
const billingAddr = lc.addresses?.billing?.[0];
|
const billingAddr = lc.addresses?.billing?.[0];
|
||||||
const email = getFirstEmail(lc);
|
const email = getFirstEmail(lc);
|
||||||
const phone = getFirstPhone(lc);
|
const phone = getFirstPhone(lc);
|
||||||
const mobile = lc.phoneNumbers?.mobile?.[0];
|
const mobile = lc.phoneNumbers?.mobile?.[0];
|
||||||
|
const emails = extractAllEmails(lc);
|
||||||
|
const phones = extractAllPhones(lc);
|
||||||
|
|
||||||
if (isPerson) {
|
if (isPerson) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -83,6 +106,8 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
||||||
city: billingAddr?.city || undefined,
|
city: billingAddr?.city || undefined,
|
||||||
country: billingAddr?.countryCode || 'DE',
|
country: billingAddr?.countryCode || 'DE',
|
||||||
notes: lc.note || undefined,
|
notes: lc.note || undefined,
|
||||||
|
emails,
|
||||||
|
phones,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +125,8 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
||||||
city: billingAddr?.city || undefined,
|
city: billingAddr?.city || undefined,
|
||||||
country: billingAddr?.countryCode || 'DE',
|
country: billingAddr?.countryCode || 'DE',
|
||||||
notes: lc.note || undefined,
|
notes: lc.note || undefined,
|
||||||
|
emails,
|
||||||
|
phones,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +142,8 @@ export function companyToLexwareContactBody(company: {
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
emails?: Array<{ email: string; type: string }>;
|
||||||
|
phones?: Array<{ phone: string; type: string }>;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
version: 0,
|
version: 0,
|
||||||
|
|
@ -135,11 +164,29 @@ export function companyToLexwareContactBody(company: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (company.email) {
|
// Multi-Value emails → Lexware categories
|
||||||
|
if (company.emails && company.emails.length > 0) {
|
||||||
|
const emailAddresses: Record<string, string[]> = {};
|
||||||
|
for (const e of company.emails) {
|
||||||
|
const category = emailTypeToLexwareCategory(e.type);
|
||||||
|
if (!emailAddresses[category]) emailAddresses[category] = [];
|
||||||
|
emailAddresses[category].push(e.email);
|
||||||
|
}
|
||||||
|
body.emailAddresses = emailAddresses;
|
||||||
|
} else if (company.email) {
|
||||||
body.emailAddresses = { business: [company.email] };
|
body.emailAddresses = { business: [company.email] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (company.phone) {
|
// Multi-Value phones → Lexware categories
|
||||||
|
if (company.phones && company.phones.length > 0) {
|
||||||
|
const phoneNumbers: Record<string, string[]> = {};
|
||||||
|
for (const p of company.phones) {
|
||||||
|
const category = phoneTypeToLexwareCategory(p.type);
|
||||||
|
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
||||||
|
phoneNumbers[category].push(p.phone);
|
||||||
|
}
|
||||||
|
body.phoneNumbers = phoneNumbers;
|
||||||
|
} else if (company.phone) {
|
||||||
body.phoneNumbers = { business: [company.phone] };
|
body.phoneNumbers = { business: [company.phone] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +213,8 @@ export function contactToLexwareContactBody(contact: {
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
|
emails?: Array<{ email: string; type: string }>;
|
||||||
|
phones?: Array<{ phone: string; type: string }>;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
version: 0,
|
version: 0,
|
||||||
|
|
@ -194,16 +243,36 @@ export function contactToLexwareContactBody(contact: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contact.email) {
|
// Multi-Value emails → Lexware categories
|
||||||
|
if (contact.emails && contact.emails.length > 0) {
|
||||||
|
const emailAddresses: Record<string, string[]> = {};
|
||||||
|
for (const e of contact.emails) {
|
||||||
|
const category = emailTypeToLexwareCategory(e.type);
|
||||||
|
if (!emailAddresses[category]) emailAddresses[category] = [];
|
||||||
|
emailAddresses[category].push(e.email);
|
||||||
|
}
|
||||||
|
body.emailAddresses = emailAddresses;
|
||||||
|
} else if (contact.email) {
|
||||||
body.emailAddresses = { business: [contact.email] };
|
body.emailAddresses = { business: [contact.email] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-Value phones → Lexware categories
|
||||||
|
if (contact.phones && contact.phones.length > 0) {
|
||||||
|
const phoneNumbers: Record<string, string[]> = {};
|
||||||
|
for (const p of contact.phones) {
|
||||||
|
const category = phoneTypeToLexwareCategory(p.type);
|
||||||
|
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
||||||
|
phoneNumbers[category].push(p.phone);
|
||||||
|
}
|
||||||
|
body.phoneNumbers = phoneNumbers;
|
||||||
|
} else {
|
||||||
const phoneNumbers: Record<string, string[]> = {};
|
const phoneNumbers: Record<string, string[]> = {};
|
||||||
if (contact.phone) phoneNumbers.business = [contact.phone];
|
if (contact.phone) phoneNumbers.business = [contact.phone];
|
||||||
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
||||||
if (Object.keys(phoneNumbers).length > 0) {
|
if (Object.keys(phoneNumbers).length > 0) {
|
||||||
body.phoneNumbers = phoneNumbers;
|
body.phoneNumbers = phoneNumbers;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (contact.notes) {
|
if (contact.notes) {
|
||||||
body.note = contact.notes.substring(0, 1000);
|
body.note = contact.notes.substring(0, 1000);
|
||||||
|
|
@ -324,6 +393,84 @@ function getFirstPhone(lc: LexwareContact): string | undefined {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Lexware-Emails in Multi-Value Array konvertieren.
|
||||||
|
* Mapping: business/office → WORK, private → PERSONAL, other → OTHER
|
||||||
|
*/
|
||||||
|
function extractAllEmails(lc: LexwareContact): EmailEntry[] {
|
||||||
|
const result: EmailEntry[] = [];
|
||||||
|
const emails = lc.emailAddresses;
|
||||||
|
if (!emails) return result;
|
||||||
|
|
||||||
|
let isFirst = true;
|
||||||
|
const addEmails = (addresses: string[] | undefined, type: EmailEntry['type']) => {
|
||||||
|
if (!addresses) return;
|
||||||
|
for (const addr of addresses) {
|
||||||
|
result.push({ email: addr, type, isPrimary: isFirst });
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addEmails(emails.business, 'WORK');
|
||||||
|
addEmails(emails.office, 'WORK');
|
||||||
|
addEmails(emails.private, 'PERSONAL');
|
||||||
|
addEmails(emails.other, 'OTHER');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Lexware-Phones in Multi-Value Array konvertieren.
|
||||||
|
* Mapping: business/office → OFFICE, mobile → MOBILE, fax → FAX, private/other → OFFICE
|
||||||
|
*/
|
||||||
|
function extractAllPhones(lc: LexwareContact): PhoneEntry[] {
|
||||||
|
const result: PhoneEntry[] = [];
|
||||||
|
const phones = lc.phoneNumbers;
|
||||||
|
if (!phones) return result;
|
||||||
|
|
||||||
|
let isFirst = true;
|
||||||
|
const addPhones = (numbers: string[] | undefined, type: PhoneEntry['type']) => {
|
||||||
|
if (!numbers) return;
|
||||||
|
for (const num of numbers) {
|
||||||
|
result.push({ phone: num, type, isPrimary: isFirst });
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addPhones(phones.business, 'OFFICE');
|
||||||
|
addPhones(phones.office, 'OFFICE');
|
||||||
|
addPhones(phones.mobile, 'MOBILE');
|
||||||
|
addPhones(phones.fax, 'FAX');
|
||||||
|
addPhones(phones.private, 'OFFICE');
|
||||||
|
addPhones(phones.other, 'OFFICE');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRM EmailType → Lexware Kategorie
|
||||||
|
*/
|
||||||
|
function emailTypeToLexwareCategory(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'WORK': return 'business';
|
||||||
|
case 'PERSONAL': return 'private';
|
||||||
|
case 'OTHER': return 'other';
|
||||||
|
default: return 'business';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRM PhoneType → Lexware Kategorie
|
||||||
|
*/
|
||||||
|
function phoneTypeToLexwareCategory(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'OFFICE': return 'business';
|
||||||
|
case 'MOBILE': return 'mobile';
|
||||||
|
case 'FAX': return 'fax';
|
||||||
|
default: return 'business';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Lexware VoucherList Item -> Voucher Type
|
// Lexware VoucherList Item -> Voucher Type
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
8
packages/crm-service/src/owners/owners.module.ts
Normal file
8
packages/crm-service/src/owners/owners.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OwnersService } from './owners.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [OwnersService],
|
||||||
|
exports: [OwnersService],
|
||||||
|
})
|
||||||
|
export class OwnersModule {}
|
||||||
166
packages/crm-service/src/owners/owners.service.ts
Normal file
166
packages/crm-service/src/owners/owners.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// ============================================================
|
||||||
|
// Shared Owner Service fuer Contact, Company, Deal
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OwnersService {
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Contact Owners
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async addContactOwner(tenantId: string, contactId: string, dto: AddOwnerDto) {
|
||||||
|
// Contact existiert + gehoert zum Tenant
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, tenantId },
|
||||||
|
});
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: Falls Owner bereits existiert, Rolle aktualisieren
|
||||||
|
return this.prisma.contactOwner.upsert({
|
||||||
|
where: {
|
||||||
|
contactId_userId: { contactId, userId: dto.userId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
contactId,
|
||||||
|
userId: dto.userId,
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeContactOwner(tenantId: string, contactId: string, userId: string) {
|
||||||
|
// Contact existiert + gehoert zum Tenant
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, tenantId },
|
||||||
|
});
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner suchen
|
||||||
|
const owner = await this.prisma.contactOwner.findUnique({
|
||||||
|
where: {
|
||||||
|
contactId_userId: { contactId, userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!owner) {
|
||||||
|
throw new NotFoundException('Owner nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.contactOwner.delete({
|
||||||
|
where: { id: owner.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Company Owners
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async addCompanyOwner(tenantId: string, companyId: string, dto: AddOwnerDto) {
|
||||||
|
const company = await this.prisma.company.findFirst({
|
||||||
|
where: { id: companyId, tenantId },
|
||||||
|
});
|
||||||
|
if (!company) {
|
||||||
|
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.companyOwner.upsert({
|
||||||
|
where: {
|
||||||
|
companyId_userId: { companyId, userId: dto.userId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
userId: dto.userId,
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCompanyOwner(tenantId: string, companyId: string, userId: string) {
|
||||||
|
const company = await this.prisma.company.findFirst({
|
||||||
|
where: { id: companyId, tenantId },
|
||||||
|
});
|
||||||
|
if (!company) {
|
||||||
|
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await this.prisma.companyOwner.findUnique({
|
||||||
|
where: {
|
||||||
|
companyId_userId: { companyId, userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!owner) {
|
||||||
|
throw new NotFoundException('Owner nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.companyOwner.delete({
|
||||||
|
where: { id: owner.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Deal Owners
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async addDealOwner(tenantId: string, dealId: string, dto: AddOwnerDto) {
|
||||||
|
const deal = await this.prisma.deal.findFirst({
|
||||||
|
where: { id: dealId, tenantId },
|
||||||
|
});
|
||||||
|
if (!deal) {
|
||||||
|
throw new NotFoundException('Vorgang nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.dealOwner.upsert({
|
||||||
|
where: {
|
||||||
|
dealId_userId: { dealId, userId: dto.userId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
dealId,
|
||||||
|
userId: dto.userId,
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
role: dto.role ?? 'OWNER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDealOwner(tenantId: string, dealId: string, userId: string) {
|
||||||
|
const deal = await this.prisma.deal.findFirst({
|
||||||
|
where: { id: dealId, tenantId },
|
||||||
|
});
|
||||||
|
if (!deal) {
|
||||||
|
throw new NotFoundException('Vorgang nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await this.prisma.dealOwner.findUnique({
|
||||||
|
where: {
|
||||||
|
dealId_userId: { dealId, userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!owner) {
|
||||||
|
throw new NotFoundException('Owner nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.dealOwner.delete({
|
||||||
|
where: { id: owner.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import Redis from 'ioredis';
|
||||||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(RedisService.name);
|
private readonly logger = new Logger(RedisService.name);
|
||||||
private client!: Redis;
|
private client!: Redis;
|
||||||
|
private subscriber!: Redis;
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
|
@ -19,7 +20,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
const port = this.config.get<number>('REDIS_PORT', 6379);
|
const port = this.config.get<number>('REDIS_PORT', 6379);
|
||||||
const password = this.config.get<string>('REDIS_PASSWORD');
|
const password = this.config.get<string>('REDIS_PASSWORD');
|
||||||
|
|
||||||
this.client = new Redis({
|
const redisOptions = {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
|
|
@ -34,7 +35,10 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
return Math.min(times * 200, 5000);
|
return Math.min(times * 200, 5000);
|
||||||
},
|
},
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Haupt-Client fuer Commands + Publish
|
||||||
|
this.client = new Redis(redisOptions);
|
||||||
|
|
||||||
this.client.on('error', (err: Error) => {
|
this.client.on('error', (err: Error) => {
|
||||||
this.logger.error(`Redis Fehler: ${err.message}`);
|
this.logger.error(`Redis Fehler: ${err.message}`);
|
||||||
|
|
@ -45,11 +49,27 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
|
||||||
|
// Subscriber-Client (ioredis erfordert separaten Client fuer subscribe-Modus)
|
||||||
|
this.subscriber = new Redis(redisOptions);
|
||||||
|
|
||||||
|
this.subscriber.on('error', (err: Error) => {
|
||||||
|
this.logger.error(`Redis Subscriber Fehler: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriber.on('connect', () => {
|
||||||
|
this.logger.log('Redis Subscriber Verbindung hergestellt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriber.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
this.logger.log('Trenne Redis Verbindung...');
|
this.logger.log('Trenne Redis Verbindungen...');
|
||||||
await this.client.quit();
|
await Promise.all([
|
||||||
|
this.client.quit(),
|
||||||
|
this.subscriber.quit(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ping(): Promise<string> {
|
async ping(): Promise<string> {
|
||||||
|
|
@ -85,4 +105,40 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX');
|
const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX');
|
||||||
return result === 'OK';
|
return result === 'OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Pub/Sub
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiziert eine Nachricht auf einem Redis Channel.
|
||||||
|
* Gibt die Anzahl der Empfaenger zurueck.
|
||||||
|
*/
|
||||||
|
async publish(channel: string, payload: Record<string, unknown>): Promise<number> {
|
||||||
|
return this.client.publish(channel, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abonniert einen Redis Channel.
|
||||||
|
* Callback wird bei jeder Nachricht aufgerufen.
|
||||||
|
*/
|
||||||
|
async subscribe(
|
||||||
|
channel: string,
|
||||||
|
callback: (message: Record<string, unknown>) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.subscriber.subscribe(channel);
|
||||||
|
this.subscriber.on('message', (ch: string, message: string) => {
|
||||||
|
if (ch === channel) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message) as Record<string, unknown>;
|
||||||
|
callback(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Redis Subscriber: Fehler beim Parsen der Nachricht auf "${channel}": ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.logger.log(`Redis: Abonniert auf Channel "${channel}"`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||||
EMAIL: 'E-Mail',
|
EMAIL: 'E-Mail',
|
||||||
MEETING: 'Meeting',
|
MEETING: 'Meeting',
|
||||||
TASK: 'Aufgabe',
|
TASK: 'Aufgabe',
|
||||||
|
FOLLOWUP: 'Follow-Up',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVITY_TYPES: ActivityType[] = [
|
const ACTIVITY_TYPES: ActivityType[] = [
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ import type {
|
||||||
TradeEvent,
|
TradeEvent,
|
||||||
CreateTradeEventPayload,
|
CreateTradeEventPayload,
|
||||||
UpdateTradeEventPayload,
|
UpdateTradeEventPayload,
|
||||||
|
EntityOwner,
|
||||||
|
AddOwnerPayload,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -220,6 +222,40 @@ export const companiesApi = {
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Owners (Contact, Company, Deal) ---
|
||||||
|
|
||||||
|
export const ownersApi = {
|
||||||
|
addContactOwner: (contactId: string, data: AddOwnerPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<EntityOwner>>(`/crm/contacts/${contactId}/owners`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
removeContactOwner: (contactId: string, userId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<EntityOwner>>(`/crm/contacts/${contactId}/owners/${userId}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
addCompanyOwner: (companyId: string, data: AddOwnerPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<EntityOwner>>(`/crm/companies/${companyId}/owners`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
removeCompanyOwner: (companyId: string, userId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<EntityOwner>>(`/crm/companies/${companyId}/owners/${userId}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
addDealOwner: (dealId: string, data: AddOwnerPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<EntityOwner>>(`/crm/deals/${dealId}/owners`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
removeDealOwner: (dealId: string, userId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<EntityOwner>>(`/crm/deals/${dealId}/owners/${userId}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
// --- Industries ---
|
// --- Industries ---
|
||||||
|
|
||||||
export const industriesApi = {
|
export const industriesApi = {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||||
EMAIL: 'E-Mail',
|
EMAIL: 'E-Mail',
|
||||||
MEETING: 'Meeting',
|
MEETING: 'Meeting',
|
||||||
TASK: 'Aufgabe',
|
TASK: 'Aufgabe',
|
||||||
|
FOLLOWUP: 'Follow-Up',
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSvgProps = {
|
const iconSvgProps = {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCompanies, useDeleteCompany, useCompanyContacts } from '../hooks';
|
import { useCompanies, useDeleteCompany, useCompanyContacts } from '../hooks';
|
||||||
import { CompanyFormModal } from './CompanyFormModal';
|
import { CompanyFormModal } from './CompanyFormModal';
|
||||||
import type { Company, Contact, CompaniesQueryParams, ContactType } from '../types';
|
import type { Company, Contact, CompaniesQueryParams, ContactType, EntityStatus } from '../types';
|
||||||
|
import { ENTITY_STATUS_LABELS } from '../types';
|
||||||
import styles from './CompaniesPage.module.css';
|
import styles from './CompaniesPage.module.css';
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
|
|
@ -114,11 +115,13 @@ function ContactSubRows({ companyId }: { companyId: string }) {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: contact.isActive
|
background: ((contact as Contact & { status?: EntityStatus }).status ?? (contact.isActive ? 'ACTIVE' : 'INACTIVE')) === 'ACTIVE'
|
||||||
? 'var(--color-success)'
|
? 'var(--color-success)'
|
||||||
|
: ((contact as Contact & { status?: EntityStatus }).status ?? '') === 'BLOCKED'
|
||||||
|
? '#991b1b'
|
||||||
: 'var(--color-error)',
|
: 'var(--color-error)',
|
||||||
}}
|
}}
|
||||||
title={contact.isActive ? 'Aktiv' : 'Inaktiv'}
|
title={ENTITY_STATUS_LABELS[((contact as Contact & { status?: EntityStatus }).status ?? (contact.isActive ? 'ACTIVE' : 'INACTIVE')) as EntityStatus] ?? 'Unbekannt'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{/* Leere Aktionen-Spalte */}
|
{/* Leere Aktionen-Spalte */}
|
||||||
|
|
@ -355,11 +358,13 @@ export function CompaniesPage() {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: company.isActive
|
background: (company.status ?? (company.isActive ? 'ACTIVE' : 'INACTIVE')) === 'ACTIVE'
|
||||||
? 'var(--color-success)'
|
? 'var(--color-success)'
|
||||||
|
: (company.status ?? '') === 'BLOCKED'
|
||||||
|
? '#991b1b'
|
||||||
: 'var(--color-error)',
|
: 'var(--color-error)',
|
||||||
}}
|
}}
|
||||||
title={company.isActive ? 'Aktiv' : 'Inaktiv'}
|
title={ENTITY_STATUS_LABELS[(company.status ?? (company.isActive ? 'ACTIVE' : 'INACTIVE')) as EntityStatus] ?? 'Unbekannt'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{/* Aktionen */}
|
{/* Aktionen */}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,58 @@ export function CompanyDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(company.deliveryStreet || company.deliveryZip || company.deliveryCity) && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Lieferadresse</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{company.deliveryStreet && <>{company.deliveryStreet}<br /></>}
|
||||||
|
{company.deliveryZip} {company.deliveryCity}
|
||||||
|
{company.deliveryCountry && company.deliveryCountry !== 'DE' && (
|
||||||
|
<>, {company.deliveryCountry}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.vatId && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>USt-IdNr.</span>
|
||||||
|
<span className={styles.infoValue}>{company.vatId}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.taxId && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Steuernummer</span>
|
||||||
|
<span className={styles.infoValue}>{company.taxId}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.tradeRegisterNumber && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Handelsregister</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{company.tradeRegisterNumber}
|
||||||
|
{company.registerCourt && (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}> ({company.registerCourt})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.companySize && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Groesse</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{{'SIZE_1_10': '1–10', 'SIZE_11_50': '11–50', 'SIZE_51_200': '51–200', 'SIZE_201_500': '201–500', 'SIZE_500_PLUS': '500+'}[company.companySize] ?? company.companySize} Mitarbeiter
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.dataEnrichedAt && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Datenanreicherung</span>
|
||||||
|
<span className={styles.infoValue} style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
{new Date(company.dataEnrichedAt).toLocaleDateString('de-DE')}
|
||||||
|
{company.dataEnrichedSource && ` (${company.dataEnrichedSource})`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className={styles.infoLabel}>Erstellt</span>
|
<span className={styles.infoLabel}>Erstellt</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{formatDate(company.createdAt)}
|
{formatDate(company.createdAt)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { LexwareSection } from '../lexware/LexwareSection';
|
import { LexwareSection } from '../lexware/LexwareSection';
|
||||||
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
||||||
|
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
import styles from './ContactDetailPage.module.css';
|
import styles from './ContactDetailPage.module.css';
|
||||||
|
|
||||||
const TYPE_COLORS: Record<ContactType, { bg: string; color: string }> = {
|
const TYPE_COLORS: Record<ContactType, { bg: string; color: string }> = {
|
||||||
|
|
@ -24,6 +25,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||||
EMAIL: 'E-Mail',
|
EMAIL: 'E-Mail',
|
||||||
MEETING: 'Meeting',
|
MEETING: 'Meeting',
|
||||||
TASK: 'Aufgabe',
|
TASK: 'Aufgabe',
|
||||||
|
FOLLOWUP: 'Follow-Up',
|
||||||
};
|
};
|
||||||
|
|
||||||
function activityIcon(type: ActivityType): React.ReactNode {
|
function activityIcon(type: ActivityType): React.ReactNode {
|
||||||
|
|
@ -287,6 +289,53 @@ export function ContactDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{contact.linkedinUrl && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>LinkedIn</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
<a
|
||||||
|
href={contact.linkedinUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{contact.linkedinUrl.replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '')}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contact.department && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Abteilung</span>
|
||||||
|
<span className={styles.infoValue}>{contact.department}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contact.birthday && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Geburtstag</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{new Date(contact.birthday).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contact.source && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Quelle</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{CONTACT_SOURCE_LABELS[contact.source] ?? contact.source}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contact.status && contact.status !== 'ACTIVE' && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Status</span>
|
||||||
|
<span className={styles.infoValue} style={{
|
||||||
|
color: contact.status === 'BLOCKED' ? '#991b1b' : 'var(--color-text-muted)',
|
||||||
|
}}>
|
||||||
|
{ENTITY_STATUS_LABELS[contact.status] ?? contact.status}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(contact.street || contact.zip || contact.city) && (
|
{(contact.street || contact.zip || contact.city) && (
|
||||||
<>
|
<>
|
||||||
<span className={styles.infoLabel}>Adresse</span>
|
<span className={styles.infoLabel}>Adresse</span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateContact, useUpdateContact } from '../hooks';
|
import { useCreateContact, useUpdateContact } from '../hooks';
|
||||||
import { companiesApi } from '../api';
|
import { companiesApi } from '../api';
|
||||||
import type { Contact, ContactType, Company } from '../types';
|
import type { Contact, ContactType, ContactSource, EntityStatus, Company } from '../types';
|
||||||
|
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
|
|
||||||
interface ContactFormModalProps {
|
interface ContactFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -64,6 +65,11 @@ export function ContactFormModal({
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [tagsInput, setTagsInput] = useState('');
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
const [position, setPosition] = useState('');
|
const [position, setPosition] = useState('');
|
||||||
|
const [linkedinUrl, setLinkedinUrl] = useState('');
|
||||||
|
const [birthday, setBirthday] = useState('');
|
||||||
|
const [source, setSource] = useState<ContactSource | ''>('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [status, setStatus] = useState<EntityStatus>('ACTIVE');
|
||||||
|
|
||||||
// Unternehmen-Suche
|
// Unternehmen-Suche
|
||||||
const [companySearch, setCompanySearch] = useState('');
|
const [companySearch, setCompanySearch] = useState('');
|
||||||
|
|
@ -133,6 +139,11 @@ export function ContactFormModal({
|
||||||
setNotes(contact.notes ?? '');
|
setNotes(contact.notes ?? '');
|
||||||
setTagsInput((contact.tags ?? []).join(', '));
|
setTagsInput((contact.tags ?? []).join(', '));
|
||||||
setPosition(contact.position ?? '');
|
setPosition(contact.position ?? '');
|
||||||
|
setLinkedinUrl(contact.linkedinUrl ?? '');
|
||||||
|
setBirthday(contact.birthday ? contact.birthday.split('T')[0] : '');
|
||||||
|
setSource((contact.source as ContactSource) ?? '');
|
||||||
|
setDepartment(contact.department ?? '');
|
||||||
|
setStatus(contact.status ?? 'ACTIVE');
|
||||||
if (contact.company) {
|
if (contact.company) {
|
||||||
setSelectedCompany({ id: contact.company.id, name: contact.company.name });
|
setSelectedCompany({ id: contact.company.id, name: contact.company.name });
|
||||||
setCompanySearch(contact.company.name);
|
setCompanySearch(contact.company.name);
|
||||||
|
|
@ -156,6 +167,11 @@ export function ContactFormModal({
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
setPosition('');
|
setPosition('');
|
||||||
|
setLinkedinUrl('');
|
||||||
|
setBirthday('');
|
||||||
|
setSource('');
|
||||||
|
setDepartment('');
|
||||||
|
setStatus('ACTIVE');
|
||||||
setSelectedCompany(null);
|
setSelectedCompany(null);
|
||||||
setCompanySearch('');
|
setCompanySearch('');
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +196,11 @@ export function ContactFormModal({
|
||||||
...(phone ? { phone } : {}),
|
...(phone ? { phone } : {}),
|
||||||
...(mobile ? { mobile } : {}),
|
...(mobile ? { mobile } : {}),
|
||||||
...(website ? { website } : {}),
|
...(website ? { website } : {}),
|
||||||
|
...(linkedinUrl ? { linkedinUrl } : {}),
|
||||||
|
...(birthday ? { birthday: new Date(birthday).toISOString() } : {}),
|
||||||
|
...(source ? { source } : {}),
|
||||||
|
...(department ? { department } : {}),
|
||||||
|
status,
|
||||||
...(street ? { street } : {}),
|
...(street ? { street } : {}),
|
||||||
...(zip ? { zip } : {}),
|
...(zip ? { zip } : {}),
|
||||||
...(city ? { city } : {}),
|
...(city ? { city } : {}),
|
||||||
|
|
@ -346,17 +367,28 @@ export function ContactFormModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Position (nur bei Person) */}
|
{/* Position + Abteilung (nur bei Person) */}
|
||||||
{type === 'PERSON' && (
|
{type === 'PERSON' && (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
<label style={labelStyle}>Position</label>
|
<label style={labelStyle}>Position</label>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
value={position}
|
value={position}
|
||||||
onChange={(e) => setPosition(e.target.value)}
|
onChange={(e) => setPosition(e.target.value)}
|
||||||
placeholder="z.B. Geschäftsführer, Einkäufer..."
|
placeholder="z.B. Geschaeftsfuehrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Abteilung</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={department}
|
||||||
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
|
placeholder="z.B. Einkauf, Vertrieb"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
|
|
@ -427,8 +459,9 @@ export function ContactFormModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Website */}
|
{/* Website + LinkedIn */}
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
<label style={labelStyle}>Website</label>
|
<label style={labelStyle}>Website</label>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
|
|
@ -437,6 +470,56 @@ export function ContactFormModal({
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>LinkedIn</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={linkedinUrl}
|
||||||
|
onChange={(e) => setLinkedinUrl(e.target.value)}
|
||||||
|
placeholder="https://linkedin.com/in/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Geburtsdatum + Quelle + Status */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
{type === 'PERSON' && (
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Geburtsdatum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
style={inputStyle}
|
||||||
|
value={birthday}
|
||||||
|
onChange={(e) => setBirthday(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Quelle</label>
|
||||||
|
<select
|
||||||
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value as ContactSource)}
|
||||||
|
>
|
||||||
|
<option value="">-- Keine --</option>
|
||||||
|
{Object.entries(CONTACT_SOURCE_LABELS).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Status</label>
|
||||||
|
<select
|
||||||
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as EntityStatus)}
|
||||||
|
>
|
||||||
|
{Object.entries(ENTITY_STATUS_LABELS).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Adresse */}
|
{/* Adresse */}
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { DealFormModal } from './DealFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
||||||
import type { DealStatus } from '../types';
|
import type { DealStatus } from '../types';
|
||||||
|
import { LOST_REASON_LABELS } from '../types';
|
||||||
import styles from './DealDetailPage.module.css';
|
import styles from './DealDetailPage.module.css';
|
||||||
|
|
||||||
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
|
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
|
||||||
|
|
@ -237,6 +238,20 @@ export function DealDetailPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deal.status === 'LOST' && deal.lostReason && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Verlustgrund</span>
|
||||||
|
<span className={styles.infoValue} style={{ color: '#991b1b' }}>
|
||||||
|
{LOST_REASON_LABELS[deal.lostReason] ?? deal.lostReason}
|
||||||
|
{deal.lostReasonText && (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontWeight: 400, marginLeft: '0.5rem' }}>
|
||||||
|
— {deal.lostReasonText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={styles.infoLabel}>Erstellt am</span>
|
<span className={styles.infoLabel}>Erstellt am</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{formatDate(deal.createdAt)}
|
{formatDate(deal.createdAt)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
||||||
import { contactsApi, companiesApi } from '../api';
|
import { contactsApi, companiesApi } from '../api';
|
||||||
import type { Deal, DealStatus, Contact, Company } from '../types';
|
import type { Deal, DealStatus, LostReason, Contact, Company } from '../types';
|
||||||
|
import { LOST_REASON_LABELS } from '../types';
|
||||||
|
|
||||||
interface DealFormModalProps {
|
interface DealFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -66,6 +67,8 @@ export function DealFormModal({
|
||||||
const [currency, setCurrency] = useState('EUR');
|
const [currency, setCurrency] = useState('EUR');
|
||||||
const [expectedCloseDate, setExpectedCloseDate] = useState('');
|
const [expectedCloseDate, setExpectedCloseDate] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
const [lostReason, setLostReason] = useState<LostReason | ''>('');
|
||||||
|
const [lostReasonText, setLostReasonText] = useState('');
|
||||||
|
|
||||||
// Kontakt-Suche
|
// Kontakt-Suche
|
||||||
const [contactSearch, setContactSearch] = useState('');
|
const [contactSearch, setContactSearch] = useState('');
|
||||||
|
|
@ -179,6 +182,8 @@ export function DealFormModal({
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
setNotes(deal.notes ?? '');
|
setNotes(deal.notes ?? '');
|
||||||
|
setLostReason((deal.lostReason as LostReason) ?? '');
|
||||||
|
setLostReasonText(deal.lostReasonText ?? '');
|
||||||
if (deal.contact) {
|
if (deal.contact) {
|
||||||
const { id, firstName, lastName, companyName } = deal.contact;
|
const { id, firstName, lastName, companyName } = deal.contact;
|
||||||
const name =
|
const name =
|
||||||
|
|
@ -207,6 +212,8 @@ export function DealFormModal({
|
||||||
setCurrency('EUR');
|
setCurrency('EUR');
|
||||||
setExpectedCloseDate('');
|
setExpectedCloseDate('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
|
setLostReason('');
|
||||||
|
setLostReasonText('');
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
setContactSearch('');
|
setContactSearch('');
|
||||||
setSelectedCompany(null);
|
setSelectedCompany(null);
|
||||||
|
|
@ -242,6 +249,10 @@ export function DealFormModal({
|
||||||
setError('Stage auswählen');
|
setError('Stage auswählen');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === 'LOST' && !lostReason) {
|
||||||
|
setError('Bitte einen Grund fuer den Verlust angeben');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
|
@ -254,6 +265,8 @@ export function DealFormModal({
|
||||||
currency,
|
currency,
|
||||||
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
|
...(status === 'LOST' && lostReason ? { lostReason } : {}),
|
||||||
|
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode && deal) {
|
if (isEditMode && deal) {
|
||||||
|
|
@ -601,6 +614,34 @@ export function DealFormModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lost-Reason (nur bei Status LOST) */}
|
||||||
|
{status === 'LOST' && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Verlustgrund *</label>
|
||||||
|
<select
|
||||||
|
value={lostReason}
|
||||||
|
onChange={(e) => setLostReason(e.target.value as LostReason)}
|
||||||
|
style={{ ...inputStyle, cursor: 'pointer', borderColor: !lostReason ? 'var(--color-error)' : undefined }}
|
||||||
|
>
|
||||||
|
<option value="">-- Bitte waehlen --</option>
|
||||||
|
{Object.entries(LOST_REASON_LABELS).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Bemerkung zum Verlust</label>
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 50, resize: 'vertical' }}
|
||||||
|
value={lostReasonText}
|
||||||
|
onChange={(e) => setLostReasonText(e.target.value)}
|
||||||
|
placeholder="Optionale Details..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notizen */}
|
{/* Notizen */}
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label style={labelStyle}>Notizen</label>
|
<label style={labelStyle}>Notizen</label>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
lexwareContactsApi,
|
lexwareContactsApi,
|
||||||
lexwareVouchersApi,
|
lexwareVouchersApi,
|
||||||
tradeEventsApi,
|
tradeEventsApi,
|
||||||
|
ownersApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
|
|
@ -46,6 +47,7 @@ import type {
|
||||||
LexwareVouchersQueryParams,
|
LexwareVouchersQueryParams,
|
||||||
CreateTradeEventPayload,
|
CreateTradeEventPayload,
|
||||||
UpdateTradeEventPayload,
|
UpdateTradeEventPayload,
|
||||||
|
AddOwnerPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -937,3 +939,73 @@ export function useDeleteTradeEvent() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Owners (Contact, Company, Deal)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useAddContactOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ contactId, data }: { contactId: string; data: AddOwnerPayload }) =>
|
||||||
|
ownersApi.addContactOwner(contactId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveContactOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ contactId, userId }: { contactId: string; userId: string }) =>
|
||||||
|
ownersApi.removeContactOwner(contactId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddCompanyOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ companyId, data }: { companyId: string; data: AddOwnerPayload }) =>
|
||||||
|
ownersApi.addCompanyOwner(companyId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveCompanyOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ companyId, userId }: { companyId: string; userId: string }) =>
|
||||||
|
ownersApi.removeCompanyOwner(companyId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddDealOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ dealId, data }: { dealId: string; data: AddOwnerPayload }) =>
|
||||||
|
ownersApi.addDealOwner(dealId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveDealOwner() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ dealId, userId }: { dealId: string; userId: string }) =>
|
||||||
|
ownersApi.removeDealOwner(dealId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,114 @@
|
||||||
|
|
||||||
export type ContactType = 'PERSON' | 'ORGANIZATION';
|
export type ContactType = 'PERSON' | 'ORGANIZATION';
|
||||||
export type DealStatus = 'OPEN' | 'WON' | 'LOST';
|
export type DealStatus = 'OPEN' | 'WON' | 'LOST';
|
||||||
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK';
|
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK' | 'FOLLOWUP';
|
||||||
export type ContractStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
export type ContractStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
||||||
|
|
||||||
|
// Phase 1 Enums
|
||||||
|
export type ContactSource = 'TRADE_FAIR' | 'REFERRAL' | 'WEBSITE' | 'COLD_CALL' | 'IMPORT' | 'BUSINESS_CARD' | 'OTHER';
|
||||||
|
export type EntityStatus = 'ACTIVE' | 'INACTIVE' | 'BLOCKED';
|
||||||
|
export type CompanySize = 'SIZE_1_10' | 'SIZE_11_50' | 'SIZE_51_200' | 'SIZE_201_500' | 'SIZE_500_PLUS';
|
||||||
|
export type OwnerRole = 'OWNER' | 'MEMBER' | 'WATCHER';
|
||||||
|
export type LostReason = 'PRICE' | 'TIMING' | 'COMPETITOR' | 'NO_NEED' | 'OTHER';
|
||||||
|
export type EmailType = 'WORK' | 'PERSONAL' | 'OTHER';
|
||||||
|
export type PhoneType = 'OFFICE' | 'MOBILE' | 'FAX';
|
||||||
|
|
||||||
|
// Label-Maps fuer UI
|
||||||
|
export const CONTACT_SOURCE_LABELS: Record<ContactSource, string> = {
|
||||||
|
TRADE_FAIR: 'Messe',
|
||||||
|
REFERRAL: 'Empfehlung',
|
||||||
|
WEBSITE: 'Website',
|
||||||
|
COLD_CALL: 'Kaltakquise',
|
||||||
|
IMPORT: 'Import',
|
||||||
|
BUSINESS_CARD: 'Visitenkarte',
|
||||||
|
OTHER: 'Sonstige',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITY_STATUS_LABELS: Record<EntityStatus, string> = {
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
INACTIVE: 'Inaktiv',
|
||||||
|
BLOCKED: 'Gesperrt',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMPANY_SIZE_LABELS: Record<CompanySize, string> = {
|
||||||
|
SIZE_1_10: '1–10',
|
||||||
|
SIZE_11_50: '11–50',
|
||||||
|
SIZE_51_200: '51–200',
|
||||||
|
SIZE_201_500: '201–500',
|
||||||
|
SIZE_500_PLUS: '500+',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OWNER_ROLE_LABELS: Record<OwnerRole, string> = {
|
||||||
|
OWNER: 'Verantwortlich',
|
||||||
|
MEMBER: 'Mitarbeiter',
|
||||||
|
WATCHER: 'Beobachter',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOST_REASON_LABELS: Record<LostReason, string> = {
|
||||||
|
PRICE: 'Preis',
|
||||||
|
TIMING: 'Timing',
|
||||||
|
COMPETITOR: 'Wettbewerber',
|
||||||
|
NO_NEED: 'Kein Bedarf',
|
||||||
|
OTHER: 'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMAIL_TYPE_LABELS: Record<EmailType, string> = {
|
||||||
|
WORK: 'Arbeit',
|
||||||
|
PERSONAL: 'Privat',
|
||||||
|
OTHER: 'Sonstige',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PHONE_TYPE_LABELS: Record<PhoneType, string> = {
|
||||||
|
OFFICE: 'Buero',
|
||||||
|
MOBILE: 'Mobil',
|
||||||
|
FAX: 'Fax',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Multi-Value Contact Info ---
|
||||||
|
|
||||||
|
export interface ContactEmail {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
type: EmailType;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactPhone {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
type: PhoneType;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmailPayload {
|
||||||
|
email: string;
|
||||||
|
type?: EmailType;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePhonePayload {
|
||||||
|
phone: string;
|
||||||
|
type?: PhoneType;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Owner (m:n) ---
|
||||||
|
|
||||||
|
export interface EntityOwner {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
role: OwnerRole;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddOwnerPayload {
|
||||||
|
userId: string;
|
||||||
|
role?: OwnerRole;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin-konfigurierbare Entitaeten ---
|
// --- Admin-konfigurierbare Entitaeten ---
|
||||||
|
|
||||||
export interface Industry {
|
export interface Industry {
|
||||||
|
|
@ -130,6 +235,10 @@ export interface Contact {
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
linkedinUrl: string | null;
|
||||||
|
birthday: string | null;
|
||||||
|
source: ContactSource | null;
|
||||||
|
department: string | null;
|
||||||
street: string | null;
|
street: string | null;
|
||||||
zip: string | null;
|
zip: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
|
|
@ -137,7 +246,8 @@ export interface Contact {
|
||||||
country: string;
|
country: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isActive: boolean;
|
status: EntityStatus;
|
||||||
|
isActive: boolean; // deprecated — use status
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -145,6 +255,9 @@ export interface Contact {
|
||||||
lexwareContactId: string | null;
|
lexwareContactId: string | null;
|
||||||
lexwareContactVersion: number | null;
|
lexwareContactVersion: number | null;
|
||||||
lexwareSyncedAt: string | null;
|
lexwareSyncedAt: string | null;
|
||||||
|
emails?: ContactEmail[];
|
||||||
|
phones?: ContactPhone[];
|
||||||
|
owners?: EntityOwner[];
|
||||||
activities?: Activity[];
|
activities?: Activity[];
|
||||||
company?: {
|
company?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -166,6 +279,10 @@ export interface CreateContactPayload {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
mobile?: string;
|
mobile?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
|
linkedinUrl?: string;
|
||||||
|
birthday?: string;
|
||||||
|
source?: ContactSource;
|
||||||
|
department?: string;
|
||||||
street?: string;
|
street?: string;
|
||||||
zip?: string;
|
zip?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
|
|
@ -173,7 +290,10 @@ export interface CreateContactPayload {
|
||||||
country?: string;
|
country?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
isActive?: boolean;
|
status?: EntityStatus;
|
||||||
|
isActive?: boolean; // deprecated
|
||||||
|
emails?: CreateEmailPayload[];
|
||||||
|
phones?: CreatePhonePayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateContactPayload = Partial<CreateContactPayload>;
|
export type UpdateContactPayload = Partial<CreateContactPayload>;
|
||||||
|
|
@ -282,11 +402,14 @@ export interface Deal {
|
||||||
status: DealStatus;
|
status: DealStatus;
|
||||||
expectedCloseDate: string | null;
|
expectedCloseDate: string | null;
|
||||||
closedAt: string | null;
|
closedAt: string | null;
|
||||||
|
lostReason: LostReason | null;
|
||||||
|
lostReasonText: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
owners?: EntityOwner[];
|
||||||
pipeline?: { id: string; name: string; stages?: PipelineStage[] };
|
pipeline?: { id: string; name: string; stages?: PipelineStage[] };
|
||||||
stage?: { id: string; name: string; color: string };
|
stage?: { id: string; name: string; color: string };
|
||||||
contact?: {
|
contact?: {
|
||||||
|
|
@ -310,6 +433,8 @@ export interface CreateDealPayload {
|
||||||
status?: DealStatus;
|
status?: DealStatus;
|
||||||
expectedCloseDate?: string;
|
expectedCloseDate?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
lostReason?: LostReason;
|
||||||
|
lostReasonText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
||||||
|
|
@ -325,6 +450,11 @@ export interface Company {
|
||||||
accountTypeId: string | null;
|
accountTypeId: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
|
vatId: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
tradeRegisterNumber: string | null;
|
||||||
|
registerCourt: string | null;
|
||||||
|
companySize: CompanySize | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
|
@ -333,9 +463,16 @@ export interface Company {
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
country: string;
|
country: string;
|
||||||
|
deliveryStreet: string | null;
|
||||||
|
deliveryZip: string | null;
|
||||||
|
deliveryCity: string | null;
|
||||||
|
deliveryCountry: string | null;
|
||||||
|
dataEnrichedAt: string | null;
|
||||||
|
dataEnrichedSource: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isActive: boolean;
|
status: EntityStatus;
|
||||||
|
isActive: boolean; // deprecated — use status
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -343,6 +480,9 @@ export interface Company {
|
||||||
lexwareContactId: string | null;
|
lexwareContactId: string | null;
|
||||||
lexwareContactVersion: number | null;
|
lexwareContactVersion: number | null;
|
||||||
lexwareSyncedAt: string | null;
|
lexwareSyncedAt: string | null;
|
||||||
|
emails?: ContactEmail[];
|
||||||
|
phones?: ContactPhone[];
|
||||||
|
owners?: EntityOwner[];
|
||||||
industryRef?: Industry | null;
|
industryRef?: Industry | null;
|
||||||
accountType?: AccountType | null;
|
accountType?: AccountType | null;
|
||||||
_count?: { contacts: number; deals: number; lexwareVouchers?: number; contracts?: number };
|
_count?: { contacts: number; deals: number; lexwareVouchers?: number; contracts?: number };
|
||||||
|
|
@ -353,6 +493,7 @@ export interface Company {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
|
status: EntityStatus;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}[];
|
}[];
|
||||||
deals?: Deal[];
|
deals?: Deal[];
|
||||||
|
|
@ -368,6 +509,11 @@ export interface CreateCompanyPayload {
|
||||||
accountTypeId?: string;
|
accountTypeId?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
|
vatId?: string;
|
||||||
|
taxId?: string;
|
||||||
|
tradeRegisterNumber?: string;
|
||||||
|
registerCourt?: string;
|
||||||
|
companySize?: CompanySize;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
|
|
@ -376,9 +522,16 @@ export interface CreateCompanyPayload {
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
|
deliveryStreet?: string;
|
||||||
|
deliveryZip?: string;
|
||||||
|
deliveryCity?: string;
|
||||||
|
deliveryCountry?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
isActive?: boolean;
|
status?: EntityStatus;
|
||||||
|
isActive?: boolean; // deprecated
|
||||||
|
emails?: CreateEmailPayload[];
|
||||||
|
phones?: CreatePhonePayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateCompanyPayload = Partial<CreateCompanyPayload>;
|
export type UpdateCompanyPayload = Partial<CreateCompanyPayload>;
|
||||||
|
|
@ -413,6 +566,7 @@ export interface ContactsQueryParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
type?: ContactType;
|
type?: ContactType;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
status?: EntityStatus;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +600,7 @@ export interface CompaniesQueryParams {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
industry?: string;
|
industry?: string;
|
||||||
|
status?: EntityStatus;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue