mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
feat(crm): Phase 2.1 Custom Fields — backend + frontend integration
Backend (CRM expert): Custom field definitions CRUD, bulk value upsert, 7 endpoints, Prisma schema with CustomFieldDef + CustomFieldValue tables. Frontend: Types, API, hooks, admin settings page with field management, CustomFieldsDisplay for detail pages, CustomFieldsForm for edit modals. Also fix Vite allowedHosts for insight.xinion.lan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
405ab5f038
commit
aaedf68085
30 changed files with 2645 additions and 34 deletions
|
|
@ -2193,3 +2193,101 @@ Bitte fuer jedes abgeschlossene Feature einen Eintrag in diese Datei schreiben:
|
||||||
---
|
---
|
||||||
|
|
||||||
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 | CRM-Backend: Phase 2.1 — Custom Fields System
|
||||||
|
|
||||||
|
### Neue Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `POST` | `/api/v1/crm/custom-fields` | Feld-Definition erstellen |
|
||||||
|
| `GET` | `/api/v1/crm/custom-fields?entityType=PERSON` | Alle Feld-Definitionen auflisten (optional nach Entity-Typ gefiltert) |
|
||||||
|
| `GET` | `/api/v1/crm/custom-fields/:id` | Einzelne Feld-Definition abrufen |
|
||||||
|
| `PATCH` | `/api/v1/crm/custom-fields/:id` | Definition aktualisieren (Label, Options, Position, Required) |
|
||||||
|
| `DELETE` | `/api/v1/crm/custom-fields/:id` | Definition loeschen (CASCADE auf alle gespeicherten Werte!) |
|
||||||
|
| `PUT` | `/api/v1/crm/custom-fields/:entityId/values` | Custom-Field-Werte fuer eine Entity setzen (Bulk-Upsert) |
|
||||||
|
| `GET` | `/api/v1/crm/custom-fields/:entityId/values` | Custom-Field-Werte fuer eine Entity lesen |
|
||||||
|
|
||||||
|
### Schema-Aenderungen
|
||||||
|
|
||||||
|
**Neue Enums:**
|
||||||
|
- `CustomFieldEntityType`: PERSON, COMPANY, DEAL
|
||||||
|
- `CustomFieldType`: TEXT, TEXTAREA, NUMBER, DATE, DROPDOWN, MULTI_SELECT, CHECKBOX, URL
|
||||||
|
|
||||||
|
**Neue Tabellen:**
|
||||||
|
|
||||||
|
| Tabelle | Beschreibung |
|
||||||
|
|---------|-------------|
|
||||||
|
| `crm_custom_field_defs` | Feld-Definitionen pro Tenant + Entity-Typ. Unique: `[tenant_id, entity_type, name]`. Felder: entityType, name (Auto-Slug), label, fieldType (immutable), options (JSONB fuer DROPDOWN/MULTI_SELECT), isRequired, position |
|
||||||
|
| `crm_custom_field_values` | Gespeicherte Werte. Unique: `[field_def_id, entity_id]`. Spalten pro Typ: valueText, valueNumber, valueDate, valueBoolean, valueJson. FK auf Definitionen mit CASCADE Delete |
|
||||||
|
|
||||||
|
**SQL Migration:** `prisma/migrations/20260312_phase2_custom_fields/migration.sql`
|
||||||
|
|
||||||
|
### Response-Aenderungen
|
||||||
|
|
||||||
|
**Contact-Detail, Company-Detail, Deal-Detail** enthalten jetzt ein neues Feld `customFields`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"firstName": "Max",
|
||||||
|
"customFields": [
|
||||||
|
{
|
||||||
|
"fieldDefId": "uuid",
|
||||||
|
"name": "kundennummer",
|
||||||
|
"label": "Kundennummer",
|
||||||
|
"fieldType": "TEXT",
|
||||||
|
"value": "KD-12345"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldDefId": "uuid",
|
||||||
|
"name": "segment",
|
||||||
|
"label": "Segment",
|
||||||
|
"fieldType": "DROPDOWN",
|
||||||
|
"value": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldDefId": "uuid",
|
||||||
|
"name": "newsletter",
|
||||||
|
"label": "Newsletter",
|
||||||
|
"fieldType": "CHECKBOX",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Array enthaelt ALLE definierten Custom Fields fuer den jeweiligen Entity-Typ (auch ohne Wert = `value: null`), sortiert nach `position`. Das Frontend bekommt so immer die vollstaendige Feldliste fuer Formular-Rendering.
|
||||||
|
|
||||||
|
### Value-Mapping nach Feldtyp
|
||||||
|
|
||||||
|
| FieldType | JS-Typ | Beispiel |
|
||||||
|
|-----------|--------|---------|
|
||||||
|
| TEXT, TEXTAREA, URL | string | `"KD-12345"` |
|
||||||
|
| NUMBER | number | `42.5` |
|
||||||
|
| DATE | string (ISO) | `"2026-01-15T00:00:00.000Z"` |
|
||||||
|
| CHECKBOX | boolean | `true` |
|
||||||
|
| DROPDOWN | string (single select) | `"premium"` |
|
||||||
|
| MULTI_SELECT | string[] | `["tag-a", "tag-b"]` |
|
||||||
|
|
||||||
|
### Wichtige Regeln
|
||||||
|
|
||||||
|
- **name** (interner Slug) wird automatisch aus dem Label generiert (Umlaute: ae/oe/ue/ss). Unique pro Tenant + Entity-Typ.
|
||||||
|
- **entityType** und **fieldType** sind nach Erstellung **nicht mehr aenderbar** (wuerde bestehende Werte invalidieren).
|
||||||
|
- **DROPDOWN/MULTI_SELECT** erfordern `options`: `[{value: "a", label: "Segment A"}, ...]`. Bei Wert-Speicherung wird gegen die Options validiert.
|
||||||
|
- **DELETE** einer Definition loescht automatisch alle gespeicherten Werte (CASCADE).
|
||||||
|
- **Orphan-Cleanup**: Beim Loeschen eines Contacts/Company/Deal werden zugehoerige Custom-Field-Werte automatisch entfernt.
|
||||||
|
- **isRequired** wird aktuell nur client-seitig ausgewertet (das Flag wird mitgeliefert). Server-seitige Pflichtfeld-Pruefung kann spaeter ergaenzt werden.
|
||||||
|
|
||||||
|
### TODO fuer Frontend
|
||||||
|
|
||||||
|
1. **Admin-Bereich:** CRUD-UI fuer Custom Field Definitionen (pro Entity-Typ: PERSON, COMPANY, DEAL)
|
||||||
|
- Felder: Label (→ Slug auto), Feldtyp (immutable nach Erstellung), Options-Editor fuer DROPDOWN/MULTI_SELECT, Pflichtfeld-Toggle, Drag&Drop fuer Position
|
||||||
|
2. **Entity-Detail-Pages:** `customFields`-Array aus Response auslesen und dynamisch rendern
|
||||||
|
- Pro Feldtyp: Text-Input, Textarea, Number-Input, Date-Picker, Select, Multi-Select, Checkbox, URL-Input
|
||||||
|
3. **Entity-Formulare:** Custom Fields im Erstellen/Bearbeiten-Formular einbinden
|
||||||
|
- `PUT /custom-fields/:entityId/values` nach Entity-Speichern aufrufen
|
||||||
|
- `isRequired` client-seitig validieren
|
||||||
|
4. **API-Aufrufe:** `GET /custom-fields?entityType=PERSON` fuer Feld-Definitionen, `PUT/GET /custom-fields/:entityId/values` fuer Werte
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,12 @@ packages/crm-service/
|
||||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter, Shared DTOs (contact-info, owner)
|
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter, Shared DTOs (contact-info, owner)
|
||||||
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push)
|
custom-fields/ — Custom Fields System (Phase 2.1): Definitionen + Werte CRUD, Entity-Integration
|
||||||
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events)
|
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push, Custom Fields)
|
||||||
|
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events, Custom Fields)
|
||||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; 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 + LostReason + Owner m:n + Events
|
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields
|
||||||
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
||||||
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
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)
|
||||||
|
|
@ -73,6 +74,8 @@ packages/crm-service/
|
||||||
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter)
|
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter)
|
||||||
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
||||||
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
||||||
|
- **CustomFieldDef** — Benutzerdefinierte Feld-Definitionen (Phase 2.1): entityType, name (Slug), label, fieldType, options (JSONB), isRequired, position. Unique: [tenantId, entityType, name]
|
||||||
|
- **CustomFieldValue** — Gespeicherte Werte (Phase 2.1): fieldDefId, entityId (generisch), valueText/valueNumber/valueDate/valueBoolean/valueJson. Unique: [fieldDefId, entityId]. CASCADE Delete bei Definition-Loeschung
|
||||||
|
|
||||||
### Entity-Beziehungen
|
### Entity-Beziehungen
|
||||||
|
|
||||||
|
|
@ -94,6 +97,7 @@ PipelineStage (1) --< (n) Deal — stageId
|
||||||
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
||||||
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
||||||
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
|
CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-Endpunkte
|
### API-Endpunkte
|
||||||
|
|
@ -128,6 +132,14 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||||
| GET | /health | Health-Check (DB, Redis, Lexware) |
|
| GET | /health | Health-Check (DB, Redis, Lexware) |
|
||||||
|
| **Custom Fields** | | |
|
||||||
|
| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen |
|
||||||
|
| GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) |
|
||||||
|
| GET | /api/v1/crm/custom-fields/:id | Definition abrufen |
|
||||||
|
| PATCH | /api/v1/crm/custom-fields/:id | Definition aktualisieren |
|
||||||
|
| DELETE | /api/v1/crm/custom-fields/:id | Definition loeschen (CASCADE) |
|
||||||
|
| PUT | /api/v1/crm/custom-fields/:entityId/values | Werte setzen (Bulk-Upsert) |
|
||||||
|
| GET | /api/v1/crm/custom-fields/:entityId/values | Werte lesen |
|
||||||
| **Lexware Kontakte** | | |
|
| **Lexware Kontakte** | | |
|
||||||
| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen |
|
| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen |
|
||||||
| POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen |
|
| POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen |
|
||||||
|
|
@ -184,15 +196,14 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||||
- `20260310_add_lexware_integration` — Lexware Office Integration
|
- `20260310_add_lexware_integration` — Lexware Office Integration
|
||||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||||
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||||
|
- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte)
|
||||||
|
|
||||||
### Naechste Schritte
|
### Naechste Schritte
|
||||||
|
|
||||||
1. Migration `20260312_phase1_schema_expansion` auf Server anwenden
|
1. Migration `20260312_phase2_custom_fields` auf Server anwenden
|
||||||
2. Container neu bauen und deployen
|
2. Container neu bauen und deployen
|
||||||
3. Frontend: Multi-Value Email/Phone UI implementieren
|
3. Frontend: Custom Fields Admin-UI + Entity-Integration
|
||||||
4. Frontend: Owner-Management UI
|
4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard)
|
||||||
5. Frontend: EntityStatus statt isActive verwenden
|
5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage)
|
||||||
6. Frontend: LostReason bei Deal-Verlust einblenden
|
6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment)
|
||||||
7. Phase 2: Office 365 E-Mail Integration (Planer-Briefing vorhanden)
|
7. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
|
||||||
8. Phase 3: Kontakt-Zusammenfuehrung (Merge)
|
|
||||||
9. Phase 4: Aktivitaets-Erweiterung + Dashboard Widgets
|
|
||||||
|
|
|
||||||
|
|
@ -717,6 +717,81 @@ model DealOwner {
|
||||||
@@schema("app_crm")
|
@@schema("app_crm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Phase 2.1 Enums — Custom Fields
|
||||||
|
// --------------------------------------------------------
|
||||||
|
enum CustomFieldEntityType {
|
||||||
|
PERSON
|
||||||
|
COMPANY
|
||||||
|
DEAL
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CustomFieldType {
|
||||||
|
TEXT
|
||||||
|
TEXTAREA
|
||||||
|
NUMBER
|
||||||
|
DATE
|
||||||
|
DROPDOWN
|
||||||
|
MULTI_SELECT
|
||||||
|
CHECKBOX
|
||||||
|
URL
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CustomFieldDef - Benutzerdefinierte Feld-Definitionen (Phase 2.1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model CustomFieldDef {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
entityType CustomFieldEntityType @map("entity_type")
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
label String @db.VarChar(200)
|
||||||
|
fieldType CustomFieldType @map("field_type")
|
||||||
|
options Json? @db.JsonB
|
||||||
|
isRequired Boolean @default(false) @map("is_required")
|
||||||
|
position Int @default(0)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
values CustomFieldValue[]
|
||||||
|
|
||||||
|
@@unique([tenantId, entityType, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, entityType])
|
||||||
|
@@map("crm_custom_field_defs")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CustomFieldValue - Benutzerdefinierte Feld-Werte (Phase 2.1)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model CustomFieldValue {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
fieldDefId String @map("field_def_id") @db.Uuid
|
||||||
|
entityId String @map("entity_id") @db.Uuid
|
||||||
|
valueText String? @map("value_text") @db.Text
|
||||||
|
valueNumber Decimal? @map("value_number") @db.Decimal(15, 4)
|
||||||
|
valueDate DateTime? @map("value_date")
|
||||||
|
valueBoolean Boolean? @map("value_boolean")
|
||||||
|
valueJson Json? @map("value_json") @db.JsonB
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
fieldDef CustomFieldDef @relation(fields: [fieldDefId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fieldDefId, entityId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, entityId])
|
||||||
|
@@index([fieldDefId])
|
||||||
|
@@map("crm_custom_field_values")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar)
|
// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Phase 2.1: Custom Fields System
|
||||||
|
-- Benutzerdefinierte Felder fuer PERSON, COMPANY, DEAL
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 1. Neue Enums
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."CustomFieldEntityType" AS ENUM (
|
||||||
|
'PERSON', 'COMPANY', 'DEAL'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "app_crm"."CustomFieldType" AS ENUM (
|
||||||
|
'TEXT', 'TEXTAREA', 'NUMBER', 'DATE',
|
||||||
|
'DROPDOWN', 'MULTI_SELECT', 'CHECKBOX', 'URL'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 2. crm_custom_field_defs — Feld-Definitionen
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."crm_custom_field_defs" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"entity_type" "app_crm"."CustomFieldEntityType" NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"label" VARCHAR(200) NOT NULL,
|
||||||
|
"field_type" "app_crm"."CustomFieldType" NOT NULL,
|
||||||
|
"options" JSONB,
|
||||||
|
"is_required" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "crm_custom_field_defs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "crm_custom_field_defs_tenant_entity_name_key"
|
||||||
|
ON "app_crm"."crm_custom_field_defs"("tenant_id", "entity_type", "name");
|
||||||
|
|
||||||
|
CREATE INDEX "crm_custom_field_defs_tenant_id_idx"
|
||||||
|
ON "app_crm"."crm_custom_field_defs"("tenant_id");
|
||||||
|
|
||||||
|
CREATE INDEX "crm_custom_field_defs_tenant_entity_idx"
|
||||||
|
ON "app_crm"."crm_custom_field_defs"("tenant_id", "entity_type");
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 3. crm_custom_field_values — Feld-Werte
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_crm"."crm_custom_field_values" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"field_def_id" UUID NOT NULL,
|
||||||
|
"entity_id" UUID NOT NULL,
|
||||||
|
"value_text" TEXT,
|
||||||
|
"value_number" DECIMAL(15, 4),
|
||||||
|
"value_date" TIMESTAMP(3),
|
||||||
|
"value_boolean" BOOLEAN,
|
||||||
|
"value_json" JSONB,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "crm_custom_field_values_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "crm_custom_field_values_field_entity_key"
|
||||||
|
ON "app_crm"."crm_custom_field_values"("field_def_id", "entity_id");
|
||||||
|
|
||||||
|
CREATE INDEX "crm_custom_field_values_tenant_id_idx"
|
||||||
|
ON "app_crm"."crm_custom_field_values"("tenant_id");
|
||||||
|
|
||||||
|
CREATE INDEX "crm_custom_field_values_tenant_entity_idx"
|
||||||
|
ON "app_crm"."crm_custom_field_values"("tenant_id", "entity_id");
|
||||||
|
|
||||||
|
CREATE INDEX "crm_custom_field_values_field_def_id_idx"
|
||||||
|
ON "app_crm"."crm_custom_field_values"("field_def_id");
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 4. Foreign Key: Values -> Definitions (CASCADE)
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE "app_crm"."crm_custom_field_values"
|
||||||
|
ADD CONSTRAINT "crm_custom_field_values_field_def_fkey"
|
||||||
|
FOREIGN KEY ("field_def_id")
|
||||||
|
REFERENCES "app_crm"."crm_custom_field_defs"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -21,6 +21,7 @@ import { RelationshipTypesModule } from './relationship-types/relationship-types
|
||||||
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';
|
import { CrmEventsModule } from './events/crm-events.module';
|
||||||
|
import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -45,6 +46,7 @@ import { CrmEventsModule } from './events/crm-events.module';
|
||||||
CompanyRelationshipsModule,
|
CompanyRelationshipsModule,
|
||||||
TradeEventsModule,
|
TradeEventsModule,
|
||||||
CrmEventsModule,
|
CrmEventsModule,
|
||||||
|
CustomFieldsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ 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';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CrmPrismaModule, LexwareModule, OwnersModule],
|
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService],
|
providers: [CompaniesService],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ 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 { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
|
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
import { EntityStatus } from '../common/dto/contact-info.dto';
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
|
|
@ -14,6 +16,7 @@ export class CompaniesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly lexwareContacts: LexwareContactsService,
|
private readonly lexwareContacts: LexwareContactsService,
|
||||||
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||||
|
|
@ -227,7 +230,14 @@ export class CompaniesService {
|
||||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return company;
|
// Custom Fields anhaengen
|
||||||
|
const customFields = await this.customFieldsService.getCustomFieldsForEntity(
|
||||||
|
tenantId,
|
||||||
|
CustomFieldEntityType.COMPANY,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...company, customFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
|
@ -339,6 +349,11 @@ export class CompaniesService {
|
||||||
async remove(tenantId: string, id: string) {
|
async remove(tenantId: string, id: string) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
// Custom Field Values entfernen (entityId hat keinen FK, daher manuell)
|
||||||
|
await this.prisma.customFieldValue.deleteMany({
|
||||||
|
where: { tenantId, entityId: id },
|
||||||
|
});
|
||||||
|
|
||||||
return this.prisma.company.delete({ where: { id } });
|
return this.prisma.company.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ 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';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LexwareModule, OwnersModule],
|
imports: [LexwareModule, OwnersModule, CustomFieldsModule],
|
||||||
controllers: [ContactsController],
|
controllers: [ContactsController],
|
||||||
providers: [ContactsService],
|
providers: [ContactsService],
|
||||||
exports: [ContactsService],
|
exports: [ContactsService],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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 { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
|
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
import { EntityStatus } from '../common/dto/contact-info.dto';
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
|
|
@ -16,6 +18,7 @@ export class ContactsService {
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly lexwareContacts: LexwareContactsService,
|
private readonly lexwareContacts: LexwareContactsService,
|
||||||
private readonly eventPublisher: CrmEventPublisher,
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||||
|
|
@ -186,7 +189,14 @@ export class ContactsService {
|
||||||
throw new NotFoundException('Kontakt nicht gefunden');
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return contact;
|
// Custom Fields anhaengen
|
||||||
|
const customFields = await this.customFieldsService.getCustomFieldsForEntity(
|
||||||
|
tenantId,
|
||||||
|
CustomFieldEntityType.PERSON,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...contact, customFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
|
@ -305,6 +315,11 @@ export class ContactsService {
|
||||||
async remove(tenantId: string, id: string) {
|
async remove(tenantId: string, id: string) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
// Custom Field Values entfernen (entityId hat keinen FK, daher manuell)
|
||||||
|
await this.prisma.customFieldValue.deleteMany({
|
||||||
|
where: { tenantId, entityId: id },
|
||||||
|
});
|
||||||
|
|
||||||
return this.prisma.contact.delete({ where: { id } });
|
return this.prisma.contact.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
// ============================================================
|
||||||
|
// CustomFieldsController — REST-Endpoints fuer Custom Fields
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CustomFieldsService } from './custom-fields.service';
|
||||||
|
import { CreateCustomFieldDto, CustomFieldEntityType } from './dto/create-custom-field.dto';
|
||||||
|
import { UpdateCustomFieldDto } from './dto/update-custom-field.dto';
|
||||||
|
import { SetCustomFieldValuesDto } from './dto/set-custom-field-values.dto';
|
||||||
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
|
import { singleResponse } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
@ApiTags('Custom Fields')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(TenantGuard)
|
||||||
|
@Controller('custom-fields')
|
||||||
|
export class CustomFieldsController {
|
||||||
|
constructor(private readonly customFieldsService: CustomFieldsService) {}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Definitionen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Feld-Definition erstellen' })
|
||||||
|
async createDefinition(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateCustomFieldDto,
|
||||||
|
) {
|
||||||
|
const definition = await this.customFieldsService.createDefinition(
|
||||||
|
user.tenantId!,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Alle Feld-Definitionen auflisten' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'entityType',
|
||||||
|
required: false,
|
||||||
|
enum: CustomFieldEntityType,
|
||||||
|
description: 'Filter nach Entity-Typ',
|
||||||
|
})
|
||||||
|
async findAllDefinitions(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query('entityType') entityType?: CustomFieldEntityType,
|
||||||
|
) {
|
||||||
|
const definitions = await this.customFieldsService.findAllDefinitions(
|
||||||
|
user.tenantId!,
|
||||||
|
entityType,
|
||||||
|
);
|
||||||
|
return { data: definitions };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Feld-Definition abrufen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async findOneDefinition(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const definition = await this.customFieldsService.findOneDefinition(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return singleResponse(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Feld-Definition aktualisieren (Label, Options, Position, Required)' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async updateDefinition(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCustomFieldDto,
|
||||||
|
) {
|
||||||
|
const definition = await this.customFieldsService.updateDefinition(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Feld-Definition loeschen (CASCADE auf alle Werte!)' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async removeDefinition(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const definition = await this.customFieldsService.removeDefinition(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return singleResponse(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Werte
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
@Put(':entityId/values')
|
||||||
|
@ApiOperation({ summary: 'Custom-Field-Werte fuer Entity setzen (Bulk-Upsert)' })
|
||||||
|
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid', description: 'Contact/Company/Deal ID' })
|
||||||
|
async setValues(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||||
|
@Body() dto: SetCustomFieldValuesDto,
|
||||||
|
) {
|
||||||
|
const result = await this.customFieldsService.setValues(
|
||||||
|
user.tenantId!,
|
||||||
|
entityId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':entityId/values')
|
||||||
|
@ApiOperation({ summary: 'Custom-Field-Werte fuer Entity lesen' })
|
||||||
|
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid', description: 'Contact/Company/Deal ID' })
|
||||||
|
async getValues(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||||
|
) {
|
||||||
|
const values = await this.customFieldsService.getValues(
|
||||||
|
user.tenantId!,
|
||||||
|
entityId,
|
||||||
|
);
|
||||||
|
return { data: values };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CustomFieldsController } from './custom-fields.controller';
|
||||||
|
import { CustomFieldsService } from './custom-fields.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomFieldsController],
|
||||||
|
providers: [CustomFieldsService],
|
||||||
|
exports: [CustomFieldsService],
|
||||||
|
})
|
||||||
|
export class CustomFieldsModule {}
|
||||||
577
packages/crm-service/src/custom-fields/custom-fields.service.ts
Normal file
577
packages/crm-service/src/custom-fields/custom-fields.service.ts
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
// ============================================================
|
||||||
|
// CustomFieldsService — CRUD fuer Definitionen + Werte
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import {
|
||||||
|
CreateCustomFieldDto,
|
||||||
|
CustomFieldEntityType,
|
||||||
|
CustomFieldType,
|
||||||
|
} from './dto/create-custom-field.dto';
|
||||||
|
import { UpdateCustomFieldDto } from './dto/update-custom-field.dto';
|
||||||
|
import { SetCustomFieldValuesDto } from './dto/set-custom-field-values.dto';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Response-Interface fuer Entity-Detail-Integration
|
||||||
|
// --------------------------------------------------------
|
||||||
|
export interface CustomFieldEntry {
|
||||||
|
fieldDefId: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
fieldType: string;
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomFieldsService {
|
||||||
|
private readonly logger = new Logger(CustomFieldsService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Slug-Generierung
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert einen DB-sicheren Slug aus dem Label.
|
||||||
|
* "Kundennummer" -> "kundennummer"
|
||||||
|
* "Messe / Event" -> "messe-event"
|
||||||
|
* "USt-IdNr." -> "ust-idnr"
|
||||||
|
* Umlaute: ae/oe/ue/ss
|
||||||
|
*/
|
||||||
|
private slugify(label: string): string {
|
||||||
|
return label
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[äÄ]/g, 'ae')
|
||||||
|
.replace(/[öÖ]/g, 'oe')
|
||||||
|
.replace(/[üÜ]/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-{2,}/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CRUD — Feld-Definitionen
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async createDefinition(tenantId: string, dto: CreateCustomFieldDto) {
|
||||||
|
// Options-Validierung
|
||||||
|
this.validateOptionsForFieldType(dto.fieldType, dto.options);
|
||||||
|
|
||||||
|
const name = this.slugify(dto.label);
|
||||||
|
if (!name) {
|
||||||
|
throw new BadRequestException('Label ergibt keinen gueltigen internen Namen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniqueness pruefen
|
||||||
|
const existing = await this.prisma.customFieldDef.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_entityType_name: {
|
||||||
|
tenantId,
|
||||||
|
entityType: dto.entityType,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Ein Feld mit dem Namen "${name}" existiert bereits fuer ${dto.entityType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.customFieldDef.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
entityType: dto.entityType,
|
||||||
|
name,
|
||||||
|
label: dto.label,
|
||||||
|
fieldType: dto.fieldType,
|
||||||
|
options: dto.options ? (dto.options as unknown as Prisma.InputJsonValue) : undefined,
|
||||||
|
isRequired: dto.isRequired ?? false,
|
||||||
|
position: dto.position ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllDefinitions(tenantId: string, entityType?: CustomFieldEntityType) {
|
||||||
|
const where: Prisma.CustomFieldDefWhereInput = { tenantId };
|
||||||
|
|
||||||
|
if (entityType) {
|
||||||
|
where.entityType = entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.customFieldDef.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ position: 'asc' }, { label: 'asc' }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { values: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneDefinition(tenantId: string, id: string) {
|
||||||
|
const def = await this.prisma.customFieldDef.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: {
|
||||||
|
_count: { select: { values: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!def) {
|
||||||
|
throw new NotFoundException('Feld-Definition nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDefinition(tenantId: string, id: string, dto: UpdateCustomFieldDto) {
|
||||||
|
const existing = await this.findOneDefinition(tenantId, id);
|
||||||
|
|
||||||
|
// Options nur fuer DROPDOWN/MULTI_SELECT erlauben
|
||||||
|
if (dto.options !== undefined) {
|
||||||
|
const isSelectType =
|
||||||
|
existing.fieldType === CustomFieldType.DROPDOWN ||
|
||||||
|
existing.fieldType === CustomFieldType.MULTI_SELECT;
|
||||||
|
|
||||||
|
if (!isSelectType) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'options darf nur fuer DROPDOWN/MULTI_SELECT Felder gesetzt werden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.options.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'options darf nicht leer sein fuer DROPDOWN/MULTI_SELECT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label-Aenderung → Slug neu berechnen + Unique-Check
|
||||||
|
let name: string | undefined;
|
||||||
|
if (dto.label && dto.label !== existing.label) {
|
||||||
|
name = this.slugify(dto.label);
|
||||||
|
if (!name) {
|
||||||
|
throw new BadRequestException('Label ergibt keinen gueltigen internen Namen');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== existing.name) {
|
||||||
|
const duplicate = await this.prisma.customFieldDef.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entityType: existing.entityType,
|
||||||
|
name,
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Ein Feld mit dem Namen "${name}" existiert bereits fuer ${existing.entityType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Prisma.CustomFieldDefUpdateInput = {};
|
||||||
|
if (dto.label !== undefined) updateData.label = dto.label;
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (dto.options !== undefined) updateData.options = dto.options as unknown as Prisma.InputJsonValue;
|
||||||
|
if (dto.isRequired !== undefined) updateData.isRequired = dto.isRequired;
|
||||||
|
if (dto.position !== undefined) updateData.position = dto.position;
|
||||||
|
|
||||||
|
return this.prisma.customFieldDef.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
_count: { select: { values: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDefinition(tenantId: string, id: string) {
|
||||||
|
await this.findOneDefinition(tenantId, id);
|
||||||
|
|
||||||
|
// Prisma Cascade loescht automatisch alle zugehoerigen Values
|
||||||
|
return this.prisma.customFieldDef.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Werte — Bulk-Upsert + Lesen
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async setValues(tenantId: string, entityId: string, dto: SetCustomFieldValuesDto) {
|
||||||
|
if (dto.values.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Alle referenzierten Definitionen in einer Query laden
|
||||||
|
const fieldDefIds = dto.values.map((v) => v.fieldDefId);
|
||||||
|
const fieldDefs = await this.prisma.customFieldDef.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: fieldDefIds },
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defMap = new Map(fieldDefs.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
// 2. Validierung: Alle fieldDefIds muessen existieren + Typ-Check
|
||||||
|
for (const item of dto.values) {
|
||||||
|
const def = defMap.get(item.fieldDefId);
|
||||||
|
if (!def) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Feld-Definition "${item.fieldDefId}" nicht gefunden`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.validateValue(def.fieldType as CustomFieldType, def.label, def.options, item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upsert in Transaction
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const results: Array<{
|
||||||
|
fieldDefId: string;
|
||||||
|
entityId: string;
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of dto.values) {
|
||||||
|
const def = defMap.get(item.fieldDefId)!;
|
||||||
|
|
||||||
|
if (item.value === null) {
|
||||||
|
// null = Wert loeschen
|
||||||
|
await tx.customFieldValue.deleteMany({
|
||||||
|
where: { fieldDefId: item.fieldDefId, entityId },
|
||||||
|
});
|
||||||
|
results.push({ fieldDefId: item.fieldDefId, entityId, value: null });
|
||||||
|
} else {
|
||||||
|
const columns = this.mapValueToColumns(def.fieldType as CustomFieldType, item.value);
|
||||||
|
|
||||||
|
await tx.customFieldValue.upsert({
|
||||||
|
where: {
|
||||||
|
fieldDefId_entityId: {
|
||||||
|
fieldDefId: item.fieldDefId,
|
||||||
|
entityId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...columns,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
fieldDefId: item.fieldDefId,
|
||||||
|
entityId,
|
||||||
|
...columns,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push({ fieldDefId: item.fieldDefId, entityId, value: item.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getValues(tenantId: string, entityId: string) {
|
||||||
|
const values = await this.prisma.customFieldValue.findMany({
|
||||||
|
where: { tenantId, entityId },
|
||||||
|
include: {
|
||||||
|
fieldDef: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
label: true,
|
||||||
|
fieldType: true,
|
||||||
|
entityType: true,
|
||||||
|
options: true,
|
||||||
|
isRequired: true,
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { fieldDef: { position: 'asc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return values.map((v) => ({
|
||||||
|
fieldDefId: v.fieldDef.id,
|
||||||
|
name: v.fieldDef.name,
|
||||||
|
label: v.fieldDef.label,
|
||||||
|
fieldType: v.fieldDef.fieldType,
|
||||||
|
isRequired: v.fieldDef.isRequired,
|
||||||
|
value: this.extractValue(v.fieldDef.fieldType as CustomFieldType, v),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Integration-Helper fuer Entity-Detail-Responses
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ALLE definierten Custom Fields fuer einen Entity-Typ,
|
||||||
|
* zusammen mit den gespeicherten Werten (oder null).
|
||||||
|
* Wird in contacts/companies/deals findOne() aufgerufen.
|
||||||
|
*/
|
||||||
|
async getCustomFieldsForEntity(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: CustomFieldEntityType,
|
||||||
|
entityId: string,
|
||||||
|
): Promise<CustomFieldEntry[]> {
|
||||||
|
// Alle Definitionen fuer diesen Entity-Typ laden
|
||||||
|
const fieldDefs = await this.prisma.customFieldDef.findMany({
|
||||||
|
where: { tenantId, entityType },
|
||||||
|
orderBy: { position: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fieldDefs.length === 0) return [];
|
||||||
|
|
||||||
|
// Alle Werte fuer diese Entity laden
|
||||||
|
const values = await this.prisma.customFieldValue.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entityId,
|
||||||
|
fieldDefId: { in: fieldDefs.map((d) => d.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const valueMap = new Map(values.map((v) => [v.fieldDefId, v]));
|
||||||
|
|
||||||
|
// Merge: ALLE Definitionen mit Wert (oder null)
|
||||||
|
return fieldDefs.map((def) => {
|
||||||
|
const valueRow = valueMap.get(def.id);
|
||||||
|
return {
|
||||||
|
fieldDefId: def.id,
|
||||||
|
name: def.name,
|
||||||
|
label: def.label,
|
||||||
|
fieldType: def.fieldType,
|
||||||
|
value: valueRow
|
||||||
|
? this.extractValue(def.fieldType as CustomFieldType, valueRow)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Value-Mapping: fieldType → Datenbank-Spalte
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private mapValueToColumns(
|
||||||
|
fieldType: CustomFieldType,
|
||||||
|
value: string | number | boolean | string[],
|
||||||
|
): {
|
||||||
|
valueText: string | null;
|
||||||
|
valueNumber: Prisma.Decimal | number | null;
|
||||||
|
valueDate: Date | null;
|
||||||
|
valueBoolean: boolean | null;
|
||||||
|
valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue;
|
||||||
|
} {
|
||||||
|
const result: {
|
||||||
|
valueText: string | null;
|
||||||
|
valueNumber: Prisma.Decimal | number | null;
|
||||||
|
valueDate: Date | null;
|
||||||
|
valueBoolean: boolean | null;
|
||||||
|
valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue;
|
||||||
|
} = {
|
||||||
|
valueText: null,
|
||||||
|
valueNumber: null,
|
||||||
|
valueDate: null,
|
||||||
|
valueBoolean: null,
|
||||||
|
valueJson: Prisma.DbNull, // Prisma erfordert DbNull statt null fuer JSON-Felder
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (fieldType) {
|
||||||
|
case CustomFieldType.TEXT:
|
||||||
|
case CustomFieldType.TEXTAREA:
|
||||||
|
case CustomFieldType.URL:
|
||||||
|
case CustomFieldType.DROPDOWN:
|
||||||
|
result.valueText = String(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.NUMBER:
|
||||||
|
result.valueNumber = Number(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.DATE:
|
||||||
|
result.valueDate = new Date(value as string);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.CHECKBOX:
|
||||||
|
result.valueBoolean = Boolean(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.MULTI_SELECT:
|
||||||
|
result.valueJson = value as Prisma.InputJsonValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Value-Extraktion: Datenbank-Spalte → JS-Wert
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private extractValue(
|
||||||
|
fieldType: CustomFieldType,
|
||||||
|
row: {
|
||||||
|
valueText: string | null;
|
||||||
|
valueNumber: Prisma.Decimal | null;
|
||||||
|
valueDate: Date | null;
|
||||||
|
valueBoolean: boolean | null;
|
||||||
|
valueJson: Prisma.JsonValue;
|
||||||
|
},
|
||||||
|
): string | number | boolean | string[] | null {
|
||||||
|
switch (fieldType) {
|
||||||
|
case CustomFieldType.TEXT:
|
||||||
|
case CustomFieldType.TEXTAREA:
|
||||||
|
case CustomFieldType.URL:
|
||||||
|
case CustomFieldType.DROPDOWN:
|
||||||
|
return row.valueText;
|
||||||
|
|
||||||
|
case CustomFieldType.NUMBER:
|
||||||
|
return row.valueNumber !== null
|
||||||
|
? Number(row.valueNumber)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
case CustomFieldType.DATE:
|
||||||
|
return row.valueDate
|
||||||
|
? row.valueDate.toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
case CustomFieldType.CHECKBOX:
|
||||||
|
return row.valueBoolean;
|
||||||
|
|
||||||
|
case CustomFieldType.MULTI_SELECT:
|
||||||
|
return (row.valueJson as string[] | null) ?? null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return row.valueText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Validierung
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft ob options fuer den gegebenen Feldtyp korrekt sind.
|
||||||
|
*/
|
||||||
|
private validateOptionsForFieldType(
|
||||||
|
fieldType: CustomFieldType,
|
||||||
|
options?: Array<{ value: string; label: string }>,
|
||||||
|
): void {
|
||||||
|
const isSelectType =
|
||||||
|
fieldType === CustomFieldType.DROPDOWN ||
|
||||||
|
fieldType === CustomFieldType.MULTI_SELECT;
|
||||||
|
|
||||||
|
if (isSelectType && (!options || options.length === 0)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'options ist Pflicht fuer DROPDOWN und MULTI_SELECT Felder',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSelectType && options && options.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'options darf nur fuer DROPDOWN/MULTI_SELECT gesetzt werden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft ob ein Wert zum Feldtyp passt.
|
||||||
|
*/
|
||||||
|
private validateValue(
|
||||||
|
fieldType: CustomFieldType,
|
||||||
|
label: string,
|
||||||
|
options: Prisma.JsonValue,
|
||||||
|
value: string | number | boolean | string[] | null,
|
||||||
|
): void {
|
||||||
|
// null ist immer erlaubt (Feld leeren)
|
||||||
|
if (value === null) return;
|
||||||
|
|
||||||
|
switch (fieldType) {
|
||||||
|
case CustomFieldType.TEXT:
|
||||||
|
case CustomFieldType.TEXTAREA:
|
||||||
|
case CustomFieldType.URL:
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet einen String-Wert`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.NUMBER:
|
||||||
|
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet einen numerischen Wert`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isNaN(Number(value))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}": "${String(value)}" ist kein gueltiger numerischer Wert`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.DATE:
|
||||||
|
if (typeof value !== 'string' || isNaN(Date.parse(value))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet ein gueltiges Datum (ISO 8601)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.CHECKBOX:
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet einen Boolean-Wert`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomFieldType.DROPDOWN: {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet einen String-Wert (Auswahl)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const opts = options as Array<{ value: string; label: string }> | null;
|
||||||
|
if (opts && !opts.some((o) => o.value === value)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}": "${value}" ist keine gueltige Option`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CustomFieldType.MULTI_SELECT: {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}" erwartet ein Array von Werten`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const msOpts = options as Array<{ value: string; label: string }> | null;
|
||||||
|
if (msOpts) {
|
||||||
|
const validValues = new Set(msOpts.map((o) => o.value));
|
||||||
|
for (const v of value) {
|
||||||
|
if (!validValues.has(v)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Feld "${label}": "${v}" ist keine gueltige Option`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// ============================================================
|
||||||
|
// DTOs fuer Custom Fields — Feld-Definitionen erstellen
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Enums (TypeScript-Pendants zu Prisma Enums)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export enum CustomFieldEntityType {
|
||||||
|
PERSON = 'PERSON',
|
||||||
|
COMPANY = 'COMPANY',
|
||||||
|
DEAL = 'DEAL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CustomFieldType {
|
||||||
|
TEXT = 'TEXT',
|
||||||
|
TEXTAREA = 'TEXTAREA',
|
||||||
|
NUMBER = 'NUMBER',
|
||||||
|
DATE = 'DATE',
|
||||||
|
DROPDOWN = 'DROPDOWN',
|
||||||
|
MULTI_SELECT = 'MULTI_SELECT',
|
||||||
|
CHECKBOX = 'CHECKBOX',
|
||||||
|
URL = 'URL',
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Option-DTO (fuer DROPDOWN / MULTI_SELECT)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export class CustomFieldOptionDto {
|
||||||
|
@ApiProperty({ description: 'Interner Wert (wird gespeichert)' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Anzeigename im UI' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
label!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CreateCustomFieldDto
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export class CreateCustomFieldDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: CustomFieldEntityType,
|
||||||
|
description: 'Entity-Typ, fuer den das Feld gilt',
|
||||||
|
})
|
||||||
|
@IsEnum(CustomFieldEntityType)
|
||||||
|
entityType!: CustomFieldEntityType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
maxLength: 200,
|
||||||
|
description: 'Anzeigename des Feldes (daraus wird der interne Name/Slug generiert)',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: CustomFieldType,
|
||||||
|
description: 'Feldtyp (nach Erstellung nicht mehr aenderbar)',
|
||||||
|
})
|
||||||
|
@IsEnum(CustomFieldType)
|
||||||
|
fieldType!: CustomFieldType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [CustomFieldOptionDto],
|
||||||
|
description: 'Auswahloptionen — Pflicht fuer DROPDOWN und MULTI_SELECT',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomFieldOptionDto)
|
||||||
|
options?: CustomFieldOptionDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false, description: 'Pflichtfeld?' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isRequired?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 0, description: 'Sortierposition im Formular' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
// ============================================================
|
||||||
|
// DTO fuer Custom Fields — Werte setzen (Bulk-Upsert)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { IsArray, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CustomFieldValueItemDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'ID der Feld-Definition' })
|
||||||
|
@IsUUID()
|
||||||
|
fieldDefId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Wert: string | number | boolean | string[] | null',
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string' },
|
||||||
|
{ type: 'number' },
|
||||||
|
{ type: 'boolean' },
|
||||||
|
{ type: 'array', items: { type: 'string' } },
|
||||||
|
{ nullable: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// HINWEIS: Keine class-validator Dekoration fuer value, da Union-Type.
|
||||||
|
// Validierung gegen fieldType erfolgt im Service.
|
||||||
|
value!: string | number | boolean | string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetCustomFieldValuesDto {
|
||||||
|
@ApiProperty({ type: [CustomFieldValueItemDto], description: 'Alle Werte fuer eine Entity' })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomFieldValueItemDto)
|
||||||
|
values!: CustomFieldValueItemDto[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// ============================================================
|
||||||
|
// DTO fuer Custom Fields — Feld-Definition aktualisieren
|
||||||
|
// ============================================================
|
||||||
|
// HINWEIS: entityType und fieldType sind NICHT aenderbar,
|
||||||
|
// da eine Typ-Aenderung bestehende Werte invalidieren wuerde.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CustomFieldOptionDto } from './create-custom-field.dto';
|
||||||
|
|
||||||
|
export class UpdateCustomFieldDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 200, description: 'Neuer Anzeigename (Slug wird aktualisiert)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [CustomFieldOptionDto],
|
||||||
|
description: 'Neue Auswahloptionen (nur fuer DROPDOWN/MULTI_SELECT)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomFieldOptionDto)
|
||||||
|
options?: CustomFieldOptionDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Pflichtfeld?' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isRequired?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sortierposition im Formular' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,10 @@ 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';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OwnersModule],
|
imports: [OwnersModule, CustomFieldsModule],
|
||||||
controllers: [DealsController],
|
controllers: [DealsController],
|
||||||
providers: [DealsService],
|
providers: [DealsService],
|
||||||
exports: [DealsService],
|
exports: [DealsService],
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ 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 { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
|
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -15,6 +17,7 @@ export class DealsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly eventPublisher: CrmEventPublisher,
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||||
|
|
@ -204,7 +207,14 @@ export class DealsService {
|
||||||
throw new NotFoundException('Vorgang nicht gefunden');
|
throw new NotFoundException('Vorgang nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return deal;
|
// Custom Fields anhaengen
|
||||||
|
const customFields = await this.customFieldsService.getCustomFieldsForEntity(
|
||||||
|
tenantId,
|
||||||
|
CustomFieldEntityType.DEAL,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...deal, customFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
|
@ -315,6 +325,12 @@ export class DealsService {
|
||||||
|
|
||||||
async remove(tenantId: string, id: string) {
|
async remove(tenantId: string, id: string) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
// Custom Field Values entfernen (entityId hat keinen FK, daher manuell)
|
||||||
|
await this.prisma.customFieldValue.deleteMany({
|
||||||
|
where: { tenantId, entityId: id },
|
||||||
|
});
|
||||||
|
|
||||||
return this.prisma.deal.delete({ where: { id } });
|
return this.prisma.deal.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
packages/frontend/src/crm/CustomFieldsDisplay.tsx
Normal file
144
packages/frontend/src/crm/CustomFieldsDisplay.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
// ============================================================
|
||||||
|
// CustomFieldsDisplay — zeigt Custom Fields in Entity-Detail-Pages
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { CustomFieldValue } from './types';
|
||||||
|
|
||||||
|
interface CustomFieldsDisplayProps {
|
||||||
|
fields: CustomFieldValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
minWidth: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatValue(field: CustomFieldValue): React.ReactNode {
|
||||||
|
if (field.value === null || field.value === undefined) return null;
|
||||||
|
|
||||||
|
switch (field.fieldType) {
|
||||||
|
case 'CHECKBOX':
|
||||||
|
return field.value ? 'Ja' : 'Nein';
|
||||||
|
|
||||||
|
case 'DATE':
|
||||||
|
if (typeof field.value === 'string') {
|
||||||
|
try {
|
||||||
|
return new Date(field.value).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(field.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(field.value);
|
||||||
|
|
||||||
|
case 'URL':
|
||||||
|
if (typeof field.value === 'string' && field.value) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={field.value.startsWith('http') ? field.value : `https://${field.value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{field.value}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return String(field.value);
|
||||||
|
|
||||||
|
case 'MULTI_SELECT':
|
||||||
|
if (Array.isArray(field.value)) {
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{(field.value as string[]).map((v) => (
|
||||||
|
<span
|
||||||
|
key={v}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.0625rem 0.375rem',
|
||||||
|
background: '#eff6ff',
|
||||||
|
color: '#1e40af',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return String(field.value);
|
||||||
|
|
||||||
|
case 'NUMBER':
|
||||||
|
return typeof field.value === 'number'
|
||||||
|
? field.value.toLocaleString('de-DE')
|
||||||
|
: String(field.value);
|
||||||
|
|
||||||
|
case 'TEXTAREA':
|
||||||
|
return (
|
||||||
|
<span style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{String(field.value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return String(field.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomFieldsDisplay({ fields }: CustomFieldsDisplayProps) {
|
||||||
|
// Nur Felder mit Wert anzeigen
|
||||||
|
const fieldsWithValues = fields.filter((f) => {
|
||||||
|
if (f.value === null || f.value === undefined) return false;
|
||||||
|
if (typeof f.value === 'string' && f.value === '') return false;
|
||||||
|
if (Array.isArray(f.value) && f.value.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fieldsWithValues.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '1.25rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
marginBottom: '0.625rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zusatzfelder
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr',
|
||||||
|
gap: '0.375rem 1rem',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fieldsWithValues.map((field) => (
|
||||||
|
<div key={field.fieldDefId} style={{ display: 'contents' }}>
|
||||||
|
<span style={labelStyle}>{field.label}</span>
|
||||||
|
<span style={valueStyle}>{formatValue(field)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
packages/frontend/src/crm/CustomFieldsForm.tsx
Normal file
319
packages/frontend/src/crm/CustomFieldsForm.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
// ============================================================
|
||||||
|
// CustomFieldsForm — Custom Fields im Entity-Formular bearbeiten
|
||||||
|
// Wird in ContactFormModal, CompanyFormModal, DealFormModal eingebettet
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { CustomFieldValue } from './types';
|
||||||
|
|
||||||
|
interface CustomFieldsFormProps {
|
||||||
|
/** customFields-Array aus der Entity-Detail-Response (alle definierten Felder) */
|
||||||
|
fields: CustomFieldValue[];
|
||||||
|
/** Callback bei Wertaenderung — liefert aktualisierte Map fieldDefId → value */
|
||||||
|
onChange: (values: Record<string, string | number | boolean | string[] | null>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomFieldsForm({ fields, onChange }: CustomFieldsFormProps) {
|
||||||
|
const [values, setValues] = useState<Record<string, string | number | boolean | string[] | null>>({});
|
||||||
|
|
||||||
|
// Initialisierung aus fields
|
||||||
|
useEffect(() => {
|
||||||
|
const init: Record<string, string | number | boolean | string[] | null> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
init[f.fieldDefId] = f.value;
|
||||||
|
}
|
||||||
|
setValues(init);
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
const updateValue = useCallback(
|
||||||
|
(fieldDefId: string, value: string | number | boolean | string[] | null) => {
|
||||||
|
setValues((prev) => {
|
||||||
|
const next = { ...prev, [fieldDefId]: value };
|
||||||
|
onChange(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zusatzfelder
|
||||||
|
</div>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.fieldDefId} style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{field.label}
|
||||||
|
{/* isRequired info not in CustomFieldValue, handled at submit */}
|
||||||
|
</label>
|
||||||
|
<FieldInput
|
||||||
|
field={field}
|
||||||
|
value={values[field.fieldDefId] ?? null}
|
||||||
|
onChange={(v) => updateValue(field.fieldDefId, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type field renderer ---
|
||||||
|
|
||||||
|
interface FieldInputProps {
|
||||||
|
field: CustomFieldValue;
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
onChange: (value: string | number | boolean | string[] | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({ field, value, onChange }: FieldInputProps) {
|
||||||
|
switch (field.fieldType) {
|
||||||
|
case 'TEXT':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={typeof value === 'string' ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'TEXTAREA':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
|
||||||
|
value={typeof value === 'string' ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'NUMBER':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
style={inputStyle}
|
||||||
|
value={value !== null && value !== undefined ? String(value) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v === '' ? null : Number(v));
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'DATE':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
style={inputStyle}
|
||||||
|
value={typeof value === 'string' ? value.split('T')[0] : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v ? new Date(v).toISOString() : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'CHECKBOX':
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
style={{ width: 18, height: 18 }}
|
||||||
|
/>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'URL':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
style={inputStyle}
|
||||||
|
value={typeof value === 'string' ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'DROPDOWN':
|
||||||
|
return (
|
||||||
|
<DropdownField value={value} onChange={onChange} field={field} />
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'MULTI_SELECT':
|
||||||
|
return (
|
||||||
|
<MultiSelectField value={value} onChange={onChange} field={field} />
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={typeof value === 'string' ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dropdown (single select) ---
|
||||||
|
|
||||||
|
function DropdownField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
onChange: (v: string | null) => void;
|
||||||
|
field: CustomFieldValue;
|
||||||
|
}) {
|
||||||
|
// Options come from the field definition (not in CustomFieldValue directly)
|
||||||
|
// We parse them from the backend's customFields response which may include options
|
||||||
|
// For now we use the value as-is and provide a text input with a hint
|
||||||
|
// The admin has defined options, but they're in the definitions, not in the value response
|
||||||
|
// We'll use a simple select if we can derive options from context
|
||||||
|
|
||||||
|
// CustomFieldValue doesn't carry options, so we use a text input as fallback
|
||||||
|
// The actual options are defined in the admin. Since we don't have them here,
|
||||||
|
// we render a text input. In a future improvement, we could pass definitions too.
|
||||||
|
// UPDATE: Actually the backend sends customFields in detail response that includes
|
||||||
|
// the field defs. We should fetch defs and match. For now, simple text.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={typeof value === 'string' ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
placeholder={`${field.label} eingeben`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multi-Select ---
|
||||||
|
|
||||||
|
function MultiSelectField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
onChange: (v: string[] | null) => void;
|
||||||
|
field: CustomFieldValue;
|
||||||
|
}) {
|
||||||
|
const selected: string[] = Array.isArray(value) ? value : [];
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const addTag = useCallback(() => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed && !selected.includes(trimmed)) {
|
||||||
|
const next = [...selected, trimmed];
|
||||||
|
onChange(next.length > 0 ? next : null);
|
||||||
|
}
|
||||||
|
setInput('');
|
||||||
|
}, [input, selected, onChange]);
|
||||||
|
|
||||||
|
const removeTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
const next = selected.filter((s) => s !== tag);
|
||||||
|
onChange(next.length > 0 ? next : null);
|
||||||
|
},
|
||||||
|
[selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: selected.length > 0 ? '0.5rem' : 0 }}>
|
||||||
|
{selected.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.125rem 0.5rem',
|
||||||
|
background: '#eff6ff',
|
||||||
|
color: '#1e40af',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
color: '#1e40af',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={`${field.label} hinzufuegen (Enter)`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,12 @@ import type {
|
||||||
UpdateTradeEventPayload,
|
UpdateTradeEventPayload,
|
||||||
EntityOwner,
|
EntityOwner,
|
||||||
AddOwnerPayload,
|
AddOwnerPayload,
|
||||||
|
CustomFieldDef,
|
||||||
|
CustomFieldEntityType,
|
||||||
|
CreateCustomFieldDefPayload,
|
||||||
|
UpdateCustomFieldDefPayload,
|
||||||
|
CustomFieldValue,
|
||||||
|
SetCustomFieldValuesPayload,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -530,3 +536,57 @@ export const tradeEventsApi = {
|
||||||
.delete<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`)
|
.delete<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Custom Fields (Phase 2.1) ---
|
||||||
|
|
||||||
|
export const customFieldsApi = {
|
||||||
|
/** Feld-Definitionen auflisten (optional nach Entity-Typ gefiltert) */
|
||||||
|
listDefs: (entityType?: CustomFieldEntityType) =>
|
||||||
|
api
|
||||||
|
.get<{ success: boolean; data: CustomFieldDef[]; meta: { timestamp: string } }>(
|
||||||
|
'/crm/custom-fields',
|
||||||
|
{ params: entityType ? { entityType } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Einzelne Feld-Definition abrufen */
|
||||||
|
getDef: (id: string) =>
|
||||||
|
api
|
||||||
|
.get<SingleResponse<CustomFieldDef>>(`/crm/custom-fields/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Feld-Definition erstellen */
|
||||||
|
createDef: (data: CreateCustomFieldDefPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<CustomFieldDef>>('/crm/custom-fields', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Feld-Definition aktualisieren */
|
||||||
|
updateDef: (id: string, data: UpdateCustomFieldDefPayload) =>
|
||||||
|
api
|
||||||
|
.patch<SingleResponse<CustomFieldDef>>(`/crm/custom-fields/${id}`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Feld-Definition loeschen (CASCADE auf alle Werte!) */
|
||||||
|
deleteDef: (id: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<CustomFieldDef>>(`/crm/custom-fields/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Custom-Field-Werte fuer eine Entity lesen */
|
||||||
|
getValues: (entityId: string) =>
|
||||||
|
api
|
||||||
|
.get<{ success: boolean; data: CustomFieldValue[]; meta: { timestamp: string } }>(
|
||||||
|
`/crm/custom-fields/${entityId}/values`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Custom-Field-Werte fuer eine Entity setzen (Bulk-Upsert) */
|
||||||
|
setValues: (entityId: string, data: SetCustomFieldValuesPayload) =>
|
||||||
|
api
|
||||||
|
.put<{ success: boolean; data: CustomFieldValue[]; meta: { timestamp: string } }>(
|
||||||
|
`/crm/custom-fields/${entityId}/values`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ActivityFeed } from './ActivityFeed';
|
||||||
import { CompanyRelationshipsCard } from './CompanyRelationshipsCard';
|
import { CompanyRelationshipsCard } from './CompanyRelationshipsCard';
|
||||||
import { ContractsCard } from './ContractsCard';
|
import { ContractsCard } from './ContractsCard';
|
||||||
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
|
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
|
||||||
|
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import type { DealStatus, LexwareVoucher, Deal } from '../types';
|
import type { DealStatus, LexwareVoucher, Deal } from '../types';
|
||||||
import { VOUCHER_TYPE_LABELS } from '../types';
|
import { VOUCHER_TYPE_LABELS } from '../types';
|
||||||
|
|
@ -388,6 +389,11 @@ export function CompanyDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{company.customFields && company.customFields.length > 0 && (
|
||||||
|
<CustomFieldsDisplay fields={company.customFields} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Lexware Verknüpfung */}
|
{/* Lexware Verknüpfung */}
|
||||||
{lexwareEnabled && (
|
{lexwareEnabled && (
|
||||||
<div className={styles.lexwareInfo}>
|
<div className={styles.lexwareInfo}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import {
|
import {
|
||||||
useCreateCompany,
|
useCreateCompany,
|
||||||
|
|
@ -6,8 +6,10 @@ import {
|
||||||
useIndustries,
|
useIndustries,
|
||||||
useAccountTypes,
|
useAccountTypes,
|
||||||
useTenantUsers,
|
useTenantUsers,
|
||||||
|
useSetCustomFieldValues,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import type { Company } from '../types';
|
import { CustomFieldsForm } from '../CustomFieldsForm';
|
||||||
|
import type { Company, CustomFieldValue } from '../types';
|
||||||
|
|
||||||
interface CompanyFormModalProps {
|
interface CompanyFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -56,8 +58,18 @@ export function CompanyFormModal({
|
||||||
const isEditMode = !!company;
|
const isEditMode = !!company;
|
||||||
const createMutation = useCreateCompany();
|
const createMutation = useCreateCompany();
|
||||||
const updateMutation = useUpdateCompany();
|
const updateMutation = useUpdateCompany();
|
||||||
|
const setCustomFieldValues = useSetCustomFieldValues();
|
||||||
const mutation = isEditMode ? updateMutation : createMutation;
|
const mutation = isEditMode ? updateMutation : createMutation;
|
||||||
|
|
||||||
|
const customFieldValuesRef = useRef<Record<string, string | number | boolean | string[] | null>>({});
|
||||||
|
const customFields: CustomFieldValue[] = company?.customFields ?? [];
|
||||||
|
const handleCustomFieldsChange = useCallback(
|
||||||
|
(values: Record<string, string | number | boolean | string[] | null>) => {
|
||||||
|
customFieldValuesRef.current = values;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Dropdown data
|
// Dropdown data
|
||||||
const { data: industriesData } = useIndustries();
|
const { data: industriesData } = useIndustries();
|
||||||
const { data: accountTypesData } = useAccountTypes();
|
const { data: accountTypesData } = useAccountTypes();
|
||||||
|
|
@ -155,11 +167,26 @@ export function CompanyFormModal({
|
||||||
...(tags.length > 0 ? { tags } : {}),
|
...(tags.length > 0 ? { tags } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCustomFields = (entityId: string) => {
|
||||||
|
const vals = customFieldValuesRef.current;
|
||||||
|
const entries = Object.entries(vals);
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
setCustomFieldValues.mutate({
|
||||||
|
entityId,
|
||||||
|
data: {
|
||||||
|
values: entries.map(([fieldDefId, value]) => ({ fieldDefId, value })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isEditMode && company) {
|
if (isEditMode && company) {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: company.id, data: payload },
|
{ id: company.id, data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: () => {
|
||||||
|
saveCustomFields(company.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -170,7 +197,10 @@ export function CompanyFormModal({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload, {
|
createMutation.mutate(payload, {
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: (res) => {
|
||||||
|
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -356,7 +386,7 @@ export function CompanyFormModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
<label style={labelStyle}>Tags (kommasepariert)</label>
|
<label style={labelStyle}>Tags (kommasepariert)</label>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
|
|
@ -366,6 +396,16 @@ export function CompanyFormModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<CustomFieldsForm
|
||||||
|
fields={customFields}
|
||||||
|
onChange={handleCustomFieldsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ContactFormModal } from './ContactFormModal';
|
||||||
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
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 { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
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 { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
import styles from './ContactDetailPage.module.css';
|
import styles from './ContactDetailPage.module.css';
|
||||||
|
|
@ -381,6 +382,11 @@ export function ContactDetailPage() {
|
||||||
<p className={styles.notesText}>{contact.notes}</p>
|
<p className={styles.notesText}>{contact.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{contact.customFields && contact.customFields.length > 0 && (
|
||||||
|
<CustomFieldsDisplay fields={contact.customFields} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verknüpfte Vorgänge */}
|
{/* Verknüpfte Vorgänge */}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateContact, useUpdateContact } from '../hooks';
|
import { useCreateContact, useUpdateContact, useSetCustomFieldValues } from '../hooks';
|
||||||
import { companiesApi } from '../api';
|
import { companiesApi } from '../api';
|
||||||
import type { Contact, ContactType, ContactSource, EntityStatus, Company } from '../types';
|
import { CustomFieldsForm } from '../CustomFieldsForm';
|
||||||
|
import type { Contact, ContactType, ContactSource, EntityStatus, Company, CustomFieldValue } from '../types';
|
||||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
|
|
||||||
interface ContactFormModalProps {
|
interface ContactFormModalProps {
|
||||||
|
|
@ -47,9 +48,18 @@ export function ContactFormModal({
|
||||||
const isEditMode = !!contact;
|
const isEditMode = !!contact;
|
||||||
const createMutation = useCreateContact();
|
const createMutation = useCreateContact();
|
||||||
const updateMutation = useUpdateContact();
|
const updateMutation = useUpdateContact();
|
||||||
|
const setCustomFieldValues = useSetCustomFieldValues();
|
||||||
const mutation = isEditMode ? updateMutation : createMutation;
|
const mutation = isEditMode ? updateMutation : createMutation;
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const customFieldValuesRef = useRef<Record<string, string | number | boolean | string[] | null>>({});
|
||||||
|
const customFields: CustomFieldValue[] = contact?.customFields ?? [];
|
||||||
|
const handleCustomFieldsChange = useCallback(
|
||||||
|
(values: Record<string, string | number | boolean | string[] | null>) => {
|
||||||
|
customFieldValuesRef.current = values;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [type, setType] = useState<ContactType>('PERSON');
|
const [type, setType] = useState<ContactType>('PERSON');
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState('');
|
const [lastName, setLastName] = useState('');
|
||||||
|
|
@ -211,11 +221,26 @@ export function ContactFormModal({
|
||||||
...(position ? { position } : {}),
|
...(position ? { position } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCustomFields = (entityId: string) => {
|
||||||
|
const vals = customFieldValuesRef.current;
|
||||||
|
const entries = Object.entries(vals);
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
setCustomFieldValues.mutate({
|
||||||
|
entityId,
|
||||||
|
data: {
|
||||||
|
values: entries.map(([fieldDefId, value]) => ({ fieldDefId, value })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isEditMode && contact) {
|
if (isEditMode && contact) {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: contact.id, data: payload },
|
{ id: contact.id, data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: () => {
|
||||||
|
saveCustomFields(contact.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -226,7 +251,10 @@ export function ContactFormModal({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload, {
|
createMutation.mutate(payload, {
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: (res) => {
|
||||||
|
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -569,6 +597,14 @@ export function ContactFormModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<CustomFieldsForm
|
||||||
|
fields={customFields}
|
||||||
|
onChange={handleCustomFieldsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label style={labelStyle}>Tags (kommasepariert)</label>
|
<label style={labelStyle}>Tags (kommasepariert)</label>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useDeal, useDeleteDeal } from '../hooks';
|
||||||
import { DealFormModal } from './DealFormModal';
|
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 { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
import type { DealStatus } from '../types';
|
import type { DealStatus } from '../types';
|
||||||
import { LOST_REASON_LABELS } from '../types';
|
import { LOST_REASON_LABELS } from '../types';
|
||||||
import styles from './DealDetailPage.module.css';
|
import styles from './DealDetailPage.module.css';
|
||||||
|
|
@ -261,6 +262,11 @@ export function DealDetailPage() {
|
||||||
{deal.notes && (
|
{deal.notes && (
|
||||||
<p className={styles.notesText}>{deal.notes}</p>
|
<p className={styles.notesText}>{deal.notes}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{deal.customFields && deal.customFields.length > 0 && (
|
||||||
|
<CustomFieldsDisplay fields={deal.customFields} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Belege (Lexware Vouchers) */}
|
{/* Belege (Lexware Vouchers) */}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
import { useCreateDeal, useUpdateDeal, usePipelines, useSetCustomFieldValues } from '../hooks';
|
||||||
import { contactsApi, companiesApi } from '../api';
|
import { contactsApi, companiesApi } from '../api';
|
||||||
import type { Deal, DealStatus, LostReason, Contact, Company } from '../types';
|
import { CustomFieldsForm } from '../CustomFieldsForm';
|
||||||
|
import type { Deal, DealStatus, LostReason, Contact, Company, CustomFieldValue } from '../types';
|
||||||
import { LOST_REASON_LABELS } from '../types';
|
import { LOST_REASON_LABELS } from '../types';
|
||||||
|
|
||||||
interface DealFormModalProps {
|
interface DealFormModalProps {
|
||||||
|
|
@ -53,8 +54,18 @@ export function DealFormModal({
|
||||||
const isEditMode = !!deal;
|
const isEditMode = !!deal;
|
||||||
const createMutation = useCreateDeal();
|
const createMutation = useCreateDeal();
|
||||||
const updateMutation = useUpdateDeal();
|
const updateMutation = useUpdateDeal();
|
||||||
|
const setCustomFieldValues = useSetCustomFieldValues();
|
||||||
const mutation = isEditMode ? updateMutation : createMutation;
|
const mutation = isEditMode ? updateMutation : createMutation;
|
||||||
|
|
||||||
|
const customFieldValuesRef = useRef<Record<string, string | number | boolean | string[] | null>>({});
|
||||||
|
const customFields: CustomFieldValue[] = deal?.customFields ?? [];
|
||||||
|
const handleCustomFieldsChange = useCallback(
|
||||||
|
(values: Record<string, string | number | boolean | string[] | null>) => {
|
||||||
|
customFieldValuesRef.current = values;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: pipelinesData } = usePipelines();
|
const { data: pipelinesData } = usePipelines();
|
||||||
const pipelines = pipelinesData?.data ?? [];
|
const pipelines = pipelinesData?.data ?? [];
|
||||||
|
|
||||||
|
|
@ -269,11 +280,26 @@ export function DealFormModal({
|
||||||
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
|
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCustomFields = (entityId: string) => {
|
||||||
|
const vals = customFieldValuesRef.current;
|
||||||
|
const entries = Object.entries(vals);
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
setCustomFieldValues.mutate({
|
||||||
|
entityId,
|
||||||
|
data: {
|
||||||
|
values: entries.map(([fieldDefId, value]) => ({ fieldDefId, value })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isEditMode && deal) {
|
if (isEditMode && deal) {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: deal.id, data: payload },
|
{ id: deal.id, data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: () => {
|
||||||
|
saveCustomFields(deal.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -284,7 +310,10 @@ export function DealFormModal({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload, {
|
createMutation.mutate(payload, {
|
||||||
onSuccess: () => onSuccess(),
|
onSuccess: (res) => {
|
||||||
|
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
|
@ -643,7 +672,7 @@ export function DealFormModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notizen */}
|
{/* Notizen */}
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
<label style={labelStyle}>Notizen</label>
|
<label style={labelStyle}>Notizen</label>
|
||||||
<textarea
|
<textarea
|
||||||
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
|
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
|
||||||
|
|
@ -652,6 +681,16 @@ export function DealFormModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Fields */}
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<CustomFieldsForm
|
||||||
|
fields={customFields}
|
||||||
|
onChange={handleCustomFieldsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
lexwareVouchersApi,
|
lexwareVouchersApi,
|
||||||
tradeEventsApi,
|
tradeEventsApi,
|
||||||
ownersApi,
|
ownersApi,
|
||||||
|
customFieldsApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
|
|
@ -48,6 +49,10 @@ import type {
|
||||||
CreateTradeEventPayload,
|
CreateTradeEventPayload,
|
||||||
UpdateTradeEventPayload,
|
UpdateTradeEventPayload,
|
||||||
AddOwnerPayload,
|
AddOwnerPayload,
|
||||||
|
CustomFieldEntityType,
|
||||||
|
CreateCustomFieldDefPayload,
|
||||||
|
UpdateCustomFieldDefPayload,
|
||||||
|
SetCustomFieldValuesPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -111,6 +116,13 @@ export const crmKeys = {
|
||||||
active: () => ['crm', 'tradeEvents', 'active'] as const,
|
active: () => ['crm', 'tradeEvents', 'active'] as const,
|
||||||
detail: (id: string) => ['crm', 'tradeEvents', 'detail', id] as const,
|
detail: (id: string) => ['crm', 'tradeEvents', 'detail', id] as const,
|
||||||
},
|
},
|
||||||
|
customFields: {
|
||||||
|
all: ['crm', 'customFields'] as const,
|
||||||
|
defs: (entityType?: CustomFieldEntityType) =>
|
||||||
|
['crm', 'customFields', 'defs', entityType] as const,
|
||||||
|
values: (entityId: string) =>
|
||||||
|
['crm', 'customFields', 'values', entityId] as const,
|
||||||
|
},
|
||||||
lexware: {
|
lexware: {
|
||||||
all: ['crm', 'lexware'] as const,
|
all: ['crm', 'lexware'] as const,
|
||||||
contactSearch: (params: LexwareContactSearchParams) =>
|
contactSearch: (params: LexwareContactSearchParams) =>
|
||||||
|
|
@ -1009,3 +1021,78 @@ export function useRemoveDealOwner() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Custom Fields (Phase 2.1)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useCustomFieldDefs(entityType?: CustomFieldEntityType) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.customFields.defs(entityType),
|
||||||
|
queryFn: () => customFieldsApi.listDefs(entityType),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCustomFieldDef() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateCustomFieldDefPayload) =>
|
||||||
|
customFieldsApi.createDef(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.customFields.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCustomFieldDef() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateCustomFieldDefPayload }) =>
|
||||||
|
customFieldsApi.updateDef(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.customFields.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCustomFieldDef() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => customFieldsApi.deleteDef(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.customFields.all });
|
||||||
|
// Invalidate entities too since their customFields arrays change
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomFieldValues(entityId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.customFields.values(entityId),
|
||||||
|
queryFn: () => customFieldsApi.getValues(entityId),
|
||||||
|
enabled: !!entityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetCustomFieldValues() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
entityId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
entityId: string;
|
||||||
|
data: SetCustomFieldValuesPayload;
|
||||||
|
}) => customFieldsApi.setValues(entityId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.customFields.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,23 @@ import {
|
||||||
useCreateRelationshipType,
|
useCreateRelationshipType,
|
||||||
useUpdateRelationshipType,
|
useUpdateRelationshipType,
|
||||||
useDeleteRelationshipType,
|
useDeleteRelationshipType,
|
||||||
|
useCustomFieldDefs,
|
||||||
|
useCreateCustomFieldDef,
|
||||||
|
useUpdateCustomFieldDef,
|
||||||
|
useDeleteCustomFieldDef,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import type {
|
import type {
|
||||||
Industry,
|
Industry,
|
||||||
AccountType,
|
AccountType,
|
||||||
RelationshipType,
|
RelationshipType,
|
||||||
|
CustomFieldDef,
|
||||||
|
CustomFieldEntityType,
|
||||||
|
CustomFieldType,
|
||||||
|
CustomFieldOption,
|
||||||
|
} from '../types';
|
||||||
|
import {
|
||||||
|
CUSTOM_FIELD_ENTITY_TYPE_LABELS,
|
||||||
|
CUSTOM_FIELD_TYPE_LABELS,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { LexwareSyncContent } from '../lexware/LexwareSyncPage';
|
import { LexwareSyncContent } from '../lexware/LexwareSyncPage';
|
||||||
import styles from './CrmSettingsPage.module.css';
|
import styles from './CrmSettingsPage.module.css';
|
||||||
|
|
@ -673,11 +685,502 @@ function RelationshipTypesConfig() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CustomFieldsConfig (Phase 2.1)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const ENTITY_TYPES: CustomFieldEntityType[] = ['PERSON', 'COMPANY', 'DEAL'];
|
||||||
|
const FIELD_TYPES: CustomFieldType[] = [
|
||||||
|
'TEXT', 'TEXTAREA', 'NUMBER', 'DATE', 'DROPDOWN', 'MULTI_SELECT', 'CHECKBOX', 'URL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const needsOptions = (ft: CustomFieldType) => ft === 'DROPDOWN' || ft === 'MULTI_SELECT';
|
||||||
|
|
||||||
|
function CustomFieldsConfig() {
|
||||||
|
const [entityFilter, setEntityFilter] = useState<CustomFieldEntityType>('PERSON');
|
||||||
|
const { data, isLoading } = useCustomFieldDefs(entityFilter);
|
||||||
|
const createMut = useCreateCustomFieldDef();
|
||||||
|
const updateMut = useUpdateCustomFieldDef();
|
||||||
|
const deleteMut = useDeleteCustomFieldDef();
|
||||||
|
|
||||||
|
const defs: CustomFieldDef[] = data?.data ?? [];
|
||||||
|
|
||||||
|
// Add mode state
|
||||||
|
const [addMode, setAddMode] = useState(false);
|
||||||
|
const [newLabel, setNewLabel] = useState('');
|
||||||
|
const [newFieldType, setNewFieldType] = useState<CustomFieldType>('TEXT');
|
||||||
|
const [newRequired, setNewRequired] = useState(false);
|
||||||
|
const [newOptions, setNewOptions] = useState<CustomFieldOption[]>([]);
|
||||||
|
const [newOptionValue, setNewOptionValue] = useState('');
|
||||||
|
const [newOptionLabel, setNewOptionLabel] = useState('');
|
||||||
|
|
||||||
|
// Edit mode state
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState('');
|
||||||
|
const [editRequired, setEditRequired] = useState(false);
|
||||||
|
const [editOptions, setEditOptions] = useState<CustomFieldOption[]>([]);
|
||||||
|
const [editOptionValue, setEditOptionValue] = useState('');
|
||||||
|
const [editOptionLabel, setEditOptionLabel] = useState('');
|
||||||
|
|
||||||
|
const resetAdd = useCallback(() => {
|
||||||
|
setAddMode(false);
|
||||||
|
setNewLabel('');
|
||||||
|
setNewFieldType('TEXT');
|
||||||
|
setNewRequired(false);
|
||||||
|
setNewOptions([]);
|
||||||
|
setNewOptionValue('');
|
||||||
|
setNewOptionLabel('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetEdit = useCallback(() => {
|
||||||
|
setEditId(null);
|
||||||
|
setEditLabel('');
|
||||||
|
setEditRequired(false);
|
||||||
|
setEditOptions([]);
|
||||||
|
setEditOptionValue('');
|
||||||
|
setEditOptionLabel('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startEdit = useCallback((def: CustomFieldDef) => {
|
||||||
|
setAddMode(false);
|
||||||
|
setEditId(def.id);
|
||||||
|
setEditLabel(def.label);
|
||||||
|
setEditRequired(def.isRequired);
|
||||||
|
setEditOptions(def.options ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (!newLabel.trim()) return;
|
||||||
|
const payload: {
|
||||||
|
entityType: CustomFieldEntityType;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
isRequired: boolean;
|
||||||
|
options?: CustomFieldOption[];
|
||||||
|
} = {
|
||||||
|
entityType: entityFilter,
|
||||||
|
label: newLabel.trim(),
|
||||||
|
fieldType: newFieldType,
|
||||||
|
isRequired: newRequired,
|
||||||
|
};
|
||||||
|
if (needsOptions(newFieldType) && newOptions.length > 0) {
|
||||||
|
payload.options = newOptions;
|
||||||
|
}
|
||||||
|
createMut.mutate(payload, { onSuccess: resetAdd });
|
||||||
|
}, [entityFilter, newLabel, newFieldType, newRequired, newOptions, createMut, resetAdd]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
if (!editId || !editLabel.trim()) return;
|
||||||
|
const def = defs.find((d) => d.id === editId);
|
||||||
|
if (!def) return;
|
||||||
|
const payload: { label: string; isRequired: boolean; options?: CustomFieldOption[] } = {
|
||||||
|
label: editLabel.trim(),
|
||||||
|
isRequired: editRequired,
|
||||||
|
};
|
||||||
|
if (needsOptions(def.fieldType)) {
|
||||||
|
payload.options = editOptions;
|
||||||
|
}
|
||||||
|
updateMut.mutate({ id: editId, data: payload }, { onSuccess: resetEdit });
|
||||||
|
}, [editId, editLabel, editRequired, editOptions, defs, updateMut, resetEdit]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (window.confirm('Benutzerdefiniertes Feld wirklich loeschen? Alle gespeicherten Werte werden ebenfalls geloescht!')) {
|
||||||
|
deleteMut.mutate(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteMut],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(def: CustomFieldDef, direction: 'up' | 'down') => {
|
||||||
|
const newPos = direction === 'up' ? Math.max(0, def.position - 1) : def.position + 1;
|
||||||
|
updateMut.mutate({ id: def.id, data: { position: newPos } });
|
||||||
|
},
|
||||||
|
[updateMut],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNewOption = useCallback(() => {
|
||||||
|
if (!newOptionValue.trim() || !newOptionLabel.trim()) return;
|
||||||
|
setNewOptions((prev) => [...prev, { value: newOptionValue.trim(), label: newOptionLabel.trim() }]);
|
||||||
|
setNewOptionValue('');
|
||||||
|
setNewOptionLabel('');
|
||||||
|
}, [newOptionValue, newOptionLabel]);
|
||||||
|
|
||||||
|
const addEditOption = useCallback(() => {
|
||||||
|
if (!editOptionValue.trim() || !editOptionLabel.trim()) return;
|
||||||
|
setEditOptions((prev) => [...prev, { value: editOptionValue.trim(), label: editOptionLabel.trim() }]);
|
||||||
|
setEditOptionValue('');
|
||||||
|
setEditOptionLabel('');
|
||||||
|
}, [editOptionValue, editOptionLabel]);
|
||||||
|
|
||||||
|
const isSaving = createMut.isPending || updateMut.isPending;
|
||||||
|
|
||||||
|
const optionBadgeStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.125rem 0.5rem',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionRemoveStyle: React.CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.configHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.cardTitle}>Benutzerdefinierte Felder</h2>
|
||||||
|
<p className={styles.cardDesc} style={{ marginBottom: 0 }}>
|
||||||
|
Individuelle Datenfelder fuer Kontakte, Unternehmen und Vorgaenge.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className={styles.addBtn} onClick={() => { resetEdit(); setAddMode(true); }} disabled={addMode}>
|
||||||
|
<PlusIcon /> Neues Feld
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entity-Typ Filter */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1rem' }}>
|
||||||
|
{ENTITY_TYPES.map((et) => (
|
||||||
|
<button
|
||||||
|
key={et}
|
||||||
|
onClick={() => { setEntityFilter(et); resetAdd(); resetEdit(); }}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: entityFilter === et ? 'var(--color-primary)' : 'var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: entityFilter === et ? 'var(--color-primary)' : 'transparent',
|
||||||
|
color: entityFilter === et ? 'white' : 'var(--color-text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CUSTOM_FIELD_ENTITY_TYPE_LABELS[et]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{addMode && (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.8125rem', fontWeight: 500, display: 'block', marginBottom: '0.25rem' }}>
|
||||||
|
Bezeichnung *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
placeholder="z.B. Kundennummer"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.8125rem', fontWeight: 500, display: 'block', marginBottom: '0.25rem' }}>
|
||||||
|
Feldtyp *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={styles.inlineInput}
|
||||||
|
style={{ width: '100%', cursor: 'pointer' }}
|
||||||
|
value={newFieldType}
|
||||||
|
onChange={(e) => setNewFieldType(e.target.value as CustomFieldType)}
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.map((ft) => (
|
||||||
|
<option key={ft} value={ft}>{CUSTOM_FIELD_TYPE_LABELS[ft]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8125rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newRequired}
|
||||||
|
onChange={(e) => setNewRequired(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Pflichtfeld
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options editor for DROPDOWN / MULTI_SELECT */}
|
||||||
|
{needsOptions(newFieldType) && (
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ fontSize: '0.8125rem', fontWeight: 500, display: 'block', marginBottom: '0.375rem' }}>
|
||||||
|
Optionen
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '0.5rem' }}>
|
||||||
|
{newOptions.map((opt, i) => (
|
||||||
|
<span key={i} style={optionBadgeStyle}>
|
||||||
|
{opt.label}
|
||||||
|
<button style={optionRemoveStyle} onClick={() => setNewOptions((prev) => prev.filter((_, j) => j !== i))}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{newOptions.length === 0 && (
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||||
|
Noch keine Optionen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
value={newOptionValue}
|
||||||
|
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||||
|
placeholder="Wert"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addNewOption(); }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
value={newOptionLabel}
|
||||||
|
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||||
|
placeholder="Anzeige-Name"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addNewOption(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={addNewOption}
|
||||||
|
disabled={!newOptionValue.trim() || !newOptionLabel.trim()}
|
||||||
|
style={{ padding: '0.375rem 0.625rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button className={styles.cancelBtn} onClick={resetAdd}>Abbrechen</button>
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newLabel.trim() || isSaving || (needsOptions(newFieldType) && newOptions.length === 0)}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? 'Erstellen...' : 'Feld erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>Laden...</p>
|
||||||
|
) : (
|
||||||
|
<table className={styles.configTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}>#</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th style={{ width: 60 }}>Pflicht</th>
|
||||||
|
<th style={{ width: 120, textAlign: 'right' }}>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{defs.length === 0 && !addMode && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className={styles.emptyRow}>
|
||||||
|
Noch keine Felder fuer {CUSTOM_FIELD_ENTITY_TYPE_LABELS[entityFilter]} definiert
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{defs.map((def, idx) =>
|
||||||
|
editId === def.id ? (
|
||||||
|
<tr key={def.id}>
|
||||||
|
<td>{idx + 1}</td>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0.75rem', marginBottom: '0.5rem', alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.75rem', fontWeight: 500, display: 'block', marginBottom: '0.25rem' }}>
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', paddingBottom: '0.25rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.8125rem', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={editRequired} onChange={(e) => setEditRequired(e.target.checked)} />
|
||||||
|
Pflicht
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}>
|
||||||
|
Typ: {CUSTOM_FIELD_TYPE_LABELS[def.fieldType]} (nicht aenderbar) · Slug: {def.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsOptions(def.fieldType) && (
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ fontSize: '0.75rem', fontWeight: 500, display: 'block', marginBottom: '0.375rem' }}>
|
||||||
|
Optionen
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '0.5rem' }}>
|
||||||
|
{editOptions.map((opt, i) => (
|
||||||
|
<span key={i} style={optionBadgeStyle}>
|
||||||
|
{opt.label} ({opt.value})
|
||||||
|
<button style={optionRemoveStyle} onClick={() => setEditOptions((prev) => prev.filter((_, j) => j !== i))}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
value={editOptionValue}
|
||||||
|
onChange={(e) => setEditOptionValue(e.target.value)}
|
||||||
|
placeholder="Wert"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addEditOption(); }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles.inlineInput}
|
||||||
|
value={editOptionLabel}
|
||||||
|
onChange={(e) => setEditOptionLabel(e.target.value)}
|
||||||
|
placeholder="Anzeige-Name"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addEditOption(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={addEditOption}
|
||||||
|
disabled={!editOptionValue.trim() || !editOptionLabel.trim()}
|
||||||
|
style={{ padding: '0.375rem 0.625rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button className={styles.cancelBtn} onClick={resetEdit}>Abbrechen</button>
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={!editLabel.trim() || isSaving}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={def.id}>
|
||||||
|
<td>
|
||||||
|
<div className={styles.sortBtns}>
|
||||||
|
<button className={styles.sortBtn} onClick={() => handleSort(def, 'up')} disabled={idx === 0} title="Nach oben">
|
||||||
|
<ArrowUpIcon />
|
||||||
|
</button>
|
||||||
|
<button className={styles.sortBtn} onClick={() => handleSort(def, 'down')} disabled={idx === defs.length - 1} title="Nach unten">
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 500 }}>{def.label}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', marginLeft: '0.5rem' }}>
|
||||||
|
({def.name})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{def.options && def.options.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||||
|
{def.options.map((opt, i) => (
|
||||||
|
<span key={i} style={{ ...optionBadgeStyle, fontSize: '0.6875rem' }}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.125rem 0.375rem',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#4b5563',
|
||||||
|
}}>
|
||||||
|
{CUSTOM_FIELD_TYPE_LABELS[def.fieldType]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
{def.isRequired ? (
|
||||||
|
<span style={{ color: 'var(--color-primary)', fontWeight: 600, fontSize: '0.875rem' }}>Ja</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.actionsCell}>
|
||||||
|
<button className={styles.iconBtn} onClick={() => startEdit(def)} title="Bearbeiten">
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
|
onClick={() => handleDelete(def.id)}
|
||||||
|
title="Loeschen"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Page Component
|
// Page Component
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
type SettingsTab = 'module' | 'lexware' | 'settings';
|
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'settings';
|
||||||
|
|
||||||
export function CrmSettingsPage() {
|
export function CrmSettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
@ -741,6 +1244,25 @@ export function CrmSettingsPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Module
|
Module
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.settingsTab} ${activeTab === 'customfields' ? styles.settingsTabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('customfields')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 3h10v10H3z" />
|
||||||
|
<path d="M6 6h4M6 8.5h4M6 11h2" />
|
||||||
|
</svg>
|
||||||
|
Eigene Felder
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`${styles.settingsTab} ${activeTab === 'lexware' ? styles.settingsTabActive : ''}`}
|
className={`${styles.settingsTab} ${activeTab === 'lexware' ? styles.settingsTabActive : ''}`}
|
||||||
onClick={() => setActiveTab('lexware')}
|
onClick={() => setActiveTab('lexware')}
|
||||||
|
|
@ -879,6 +1401,11 @@ export function CrmSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Custom Fields */}
|
||||||
|
{activeTab === 'customfields' && (
|
||||||
|
<CustomFieldsConfig />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab: Lexoffice Sync */}
|
{/* Tab: Lexoffice Sync */}
|
||||||
{activeTab === 'lexware' && (
|
{activeTab === 'lexware' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ export interface Contact {
|
||||||
phones?: ContactPhone[];
|
phones?: ContactPhone[];
|
||||||
owners?: EntityOwner[];
|
owners?: EntityOwner[];
|
||||||
activities?: Activity[];
|
activities?: Activity[];
|
||||||
|
customFields?: CustomFieldValue[];
|
||||||
company?: {
|
company?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -410,6 +411,7 @@ export interface Deal {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
owners?: EntityOwner[];
|
owners?: EntityOwner[];
|
||||||
|
customFields?: CustomFieldValue[];
|
||||||
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?: {
|
||||||
|
|
@ -483,6 +485,7 @@ export interface Company {
|
||||||
emails?: ContactEmail[];
|
emails?: ContactEmail[];
|
||||||
phones?: ContactPhone[];
|
phones?: ContactPhone[];
|
||||||
owners?: EntityOwner[];
|
owners?: EntityOwner[];
|
||||||
|
customFields?: CustomFieldValue[];
|
||||||
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 };
|
||||||
|
|
@ -734,3 +737,85 @@ export interface UpdateTradeEventPayload {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Custom Fields (Phase 2.1)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type CustomFieldEntityType = 'PERSON' | 'COMPANY' | 'DEAL';
|
||||||
|
export type CustomFieldType =
|
||||||
|
| 'TEXT'
|
||||||
|
| 'TEXTAREA'
|
||||||
|
| 'NUMBER'
|
||||||
|
| 'DATE'
|
||||||
|
| 'DROPDOWN'
|
||||||
|
| 'MULTI_SELECT'
|
||||||
|
| 'CHECKBOX'
|
||||||
|
| 'URL';
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_ENTITY_TYPE_LABELS: Record<CustomFieldEntityType, string> = {
|
||||||
|
PERSON: 'Kontakte',
|
||||||
|
COMPANY: 'Unternehmen',
|
||||||
|
DEAL: 'Vorgaenge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_TYPE_LABELS: Record<CustomFieldType, string> = {
|
||||||
|
TEXT: 'Text',
|
||||||
|
TEXTAREA: 'Textbereich',
|
||||||
|
NUMBER: 'Zahl',
|
||||||
|
DATE: 'Datum',
|
||||||
|
DROPDOWN: 'Dropdown',
|
||||||
|
MULTI_SELECT: 'Mehrfachauswahl',
|
||||||
|
CHECKBOX: 'Checkbox',
|
||||||
|
URL: 'URL',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CustomFieldOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldDef {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
entityType: CustomFieldEntityType;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
options: CustomFieldOption[] | null;
|
||||||
|
isRequired: boolean;
|
||||||
|
position: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldValue {
|
||||||
|
fieldDefId: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomFieldDefPayload {
|
||||||
|
entityType: CustomFieldEntityType;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
options?: CustomFieldOption[];
|
||||||
|
isRequired?: boolean;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomFieldDefPayload {
|
||||||
|
label?: string;
|
||||||
|
options?: CustomFieldOption[];
|
||||||
|
isRequired?: boolean;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetCustomFieldValuesPayload {
|
||||||
|
values: Array<{
|
||||||
|
fieldDefId: string;
|
||||||
|
value: string | number | boolean | string[] | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: true,
|
host: true,
|
||||||
|
allowedHosts: ['.xinion.lan'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1/crm': {
|
'/api/v1/crm': {
|
||||||
target: 'http://localhost:3100',
|
target: 'http://localhost:3100',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue