mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56: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`*
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||
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)
|
||||
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events)
|
||||
custom-fields/ — Custom Fields System (Phase 2.1): Definitionen + Werte CRUD, Entity-Integration
|
||||
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)
|
||||
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)
|
||||
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||
|
|
@ -73,6 +74,8 @@ packages/crm-service/
|
|||
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter)
|
||||
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
||||
- **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
|
||||
|
||||
|
|
@ -94,6 +97,7 @@ PipelineStage (1) --< (n) Deal — stageId
|
|||
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
||||
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
||||
RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
||||
CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
|
||||
```
|
||||
|
||||
### API-Endpunkte
|
||||
|
|
@ -128,6 +132,14 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
|||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||
| 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** | | |
|
||||
| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen |
|
||||
| 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
|
||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||
- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte)
|
||||
|
||||
### 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
|
||||
3. Frontend: Multi-Value Email/Phone UI implementieren
|
||||
4. Frontend: Owner-Management UI
|
||||
5. Frontend: EntityStatus statt isActive verwenden
|
||||
6. Frontend: LostReason bei Deal-Verlust einblenden
|
||||
7. Phase 2: Office 365 E-Mail Integration (Planer-Briefing vorhanden)
|
||||
8. Phase 3: Kontakt-Zusammenfuehrung (Merge)
|
||||
9. Phase 4: Aktivitaets-Erweiterung + Dashboard Widgets
|
||||
3. Frontend: Custom Fields Admin-UI + Entity-Integration
|
||||
4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard)
|
||||
5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage)
|
||||
6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment)
|
||||
7. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
|
||||
|
|
|
|||
|
|
@ -717,6 +717,81 @@ model DealOwner {
|
|||
@@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)
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 { TradeEventsModule } from './trade-events/trade-events.module';
|
||||
import { CrmEventsModule } from './events/crm-events.module';
|
||||
import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -45,6 +46,7 @@ import { CrmEventsModule } from './events/crm-events.module';
|
|||
CompanyRelationshipsModule,
|
||||
TradeEventsModule,
|
||||
CrmEventsModule,
|
||||
CustomFieldsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { CompaniesService } from './companies.service';
|
|||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||
|
||||
@Module({
|
||||
imports: [CrmPrismaModule, LexwareModule, OwnersModule],
|
||||
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService],
|
||||
exports: [CompaniesService],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { CreateCompanyDto } from './dto/create-company.dto';
|
|||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||
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 { EntityStatus } from '../common/dto/contact-info.dto';
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ export class CompaniesService {
|
|||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareContacts: LexwareContactsService,
|
||||
private readonly customFieldsService: CustomFieldsService,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||
|
|
@ -227,7 +230,14 @@ export class CompaniesService {
|
|||
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(
|
||||
|
|
@ -339,6 +349,11 @@ export class CompaniesService {
|
|||
async remove(tenantId: string, id: string) {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { ContactsController } from './contacts.controller';
|
|||
import { ContactsService } from './contacts.service';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||
|
||||
@Module({
|
||||
imports: [LexwareModule, OwnersModule],
|
||||
imports: [LexwareModule, OwnersModule, CustomFieldsModule],
|
||||
controllers: [ContactsController],
|
||||
providers: [ContactsService],
|
||||
exports: [ContactsService],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { UpdateContactDto } from './dto/update-contact.dto';
|
|||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||
import { LexwareContactsService } from '../lexware/lexware-contacts.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 { EntityStatus } from '../common/dto/contact-info.dto';
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ export class ContactsService {
|
|||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareContacts: LexwareContactsService,
|
||||
private readonly eventPublisher: CrmEventPublisher,
|
||||
private readonly customFieldsService: CustomFieldsService,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||
|
|
@ -186,7 +189,14 @@ export class ContactsService {
|
|||
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(
|
||||
|
|
@ -305,6 +315,11 @@ export class ContactsService {
|
|||
async remove(tenantId: string, id: string) {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { DealsService } from './deals.service';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||
|
||||
@Module({
|
||||
imports: [OwnersModule],
|
||||
imports: [OwnersModule, CustomFieldsModule],
|
||||
controllers: [DealsController],
|
||||
providers: [DealsService],
|
||||
exports: [DealsService],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { CreateDealDto } from './dto/create-deal.dto';
|
|||
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -15,6 +17,7 @@ export class DealsService {
|
|||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly eventPublisher: CrmEventPublisher,
|
||||
private readonly customFieldsService: CustomFieldsService,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||
|
|
@ -204,7 +207,14 @@ export class DealsService {
|
|||
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(
|
||||
|
|
@ -315,6 +325,12 @@ export class DealsService {
|
|||
|
||||
async remove(tenantId: string, id: string) {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
EntityOwner,
|
||||
AddOwnerPayload,
|
||||
CustomFieldDef,
|
||||
CustomFieldEntityType,
|
||||
CreateCustomFieldDefPayload,
|
||||
UpdateCustomFieldDefPayload,
|
||||
CustomFieldValue,
|
||||
SetCustomFieldValuesPayload,
|
||||
PaginatedResponse,
|
||||
SingleResponse,
|
||||
} from './types';
|
||||
|
|
@ -530,3 +536,57 @@ export const tradeEventsApi = {
|
|||
.delete<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`)
|
||||
.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 { ContractsCard } from './ContractsCard';
|
||||
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
|
||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import type { DealStatus, LexwareVoucher, Deal } from '../types';
|
||||
import { VOUCHER_TYPE_LABELS } from '../types';
|
||||
|
|
@ -388,6 +389,11 @@ export function CompanyDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Fields */}
|
||||
{company.customFields && company.customFields.length > 0 && (
|
||||
<CustomFieldsDisplay fields={company.customFields} />
|
||||
)}
|
||||
|
||||
{/* Lexware Verknüpfung */}
|
||||
{lexwareEnabled && (
|
||||
<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 {
|
||||
useCreateCompany,
|
||||
|
|
@ -6,8 +6,10 @@ import {
|
|||
useIndustries,
|
||||
useAccountTypes,
|
||||
useTenantUsers,
|
||||
useSetCustomFieldValues,
|
||||
} from '../hooks';
|
||||
import type { Company } from '../types';
|
||||
import { CustomFieldsForm } from '../CustomFieldsForm';
|
||||
import type { Company, CustomFieldValue } from '../types';
|
||||
|
||||
interface CompanyFormModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -56,8 +58,18 @@ export function CompanyFormModal({
|
|||
const isEditMode = !!company;
|
||||
const createMutation = useCreateCompany();
|
||||
const updateMutation = useUpdateCompany();
|
||||
const setCustomFieldValues = useSetCustomFieldValues();
|
||||
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
|
||||
const { data: industriesData } = useIndustries();
|
||||
const { data: accountTypesData } = useAccountTypes();
|
||||
|
|
@ -155,11 +167,26 @@ export function CompanyFormModal({
|
|||
...(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) {
|
||||
updateMutation.mutate(
|
||||
{ id: company.id, data: payload },
|
||||
{
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: () => {
|
||||
saveCustomFields(company.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -170,7 +197,10 @@ export function CompanyFormModal({
|
|||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: (res) => {
|
||||
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -356,7 +386,7 @@ export function CompanyFormModal({
|
|||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Tags (kommasepariert)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
|
|
@ -366,6 +396,16 @@ export function CompanyFormModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFields.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<CustomFieldsForm
|
||||
fields={customFields}
|
||||
onChange={handleCustomFieldsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ContactFormModal } from './ContactFormModal';
|
|||
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { LexwareSection } from '../lexware/LexwareSection';
|
||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||
import styles from './ContactDetailPage.module.css';
|
||||
|
|
@ -381,6 +382,11 @@ export function ContactDetailPage() {
|
|||
<p className={styles.notesText}>{contact.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Fields */}
|
||||
{contact.customFields && contact.customFields.length > 0 && (
|
||||
<CustomFieldsDisplay fields={contact.customFields} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 { useCreateContact, useUpdateContact } from '../hooks';
|
||||
import { useCreateContact, useUpdateContact, useSetCustomFieldValues } from '../hooks';
|
||||
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';
|
||||
|
||||
interface ContactFormModalProps {
|
||||
|
|
@ -47,9 +48,18 @@ export function ContactFormModal({
|
|||
const isEditMode = !!contact;
|
||||
const createMutation = useCreateContact();
|
||||
const updateMutation = useUpdateContact();
|
||||
const setCustomFieldValues = useSetCustomFieldValues();
|
||||
const mutation = isEditMode ? updateMutation : createMutation;
|
||||
|
||||
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 [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
|
|
@ -211,11 +221,26 @@ export function ContactFormModal({
|
|||
...(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) {
|
||||
updateMutation.mutate(
|
||||
{ id: contact.id, data: payload },
|
||||
{
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: () => {
|
||||
saveCustomFields(contact.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -226,7 +251,10 @@ export function ContactFormModal({
|
|||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: (res) => {
|
||||
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -569,6 +597,14 @@ export function ContactFormModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFields.length > 0 && (
|
||||
<CustomFieldsForm
|
||||
fields={customFields}
|
||||
onChange={handleCustomFieldsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={labelStyle}>Tags (kommasepariert)</label>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useDeal, useDeleteDeal } from '../hooks';
|
|||
import { DealFormModal } from './DealFormModal';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||
import type { DealStatus } from '../types';
|
||||
import { LOST_REASON_LABELS } from '../types';
|
||||
import styles from './DealDetailPage.module.css';
|
||||
|
|
@ -261,6 +262,11 @@ export function DealDetailPage() {
|
|||
{deal.notes && (
|
||||
<p className={styles.notesText}>{deal.notes}</p>
|
||||
)}
|
||||
|
||||
{/* Custom Fields */}
|
||||
{deal.customFields && deal.customFields.length > 0 && (
|
||||
<CustomFieldsDisplay fields={deal.customFields} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
||||
import { useCreateDeal, useUpdateDeal, usePipelines, useSetCustomFieldValues } from '../hooks';
|
||||
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';
|
||||
|
||||
interface DealFormModalProps {
|
||||
|
|
@ -53,8 +54,18 @@ export function DealFormModal({
|
|||
const isEditMode = !!deal;
|
||||
const createMutation = useCreateDeal();
|
||||
const updateMutation = useUpdateDeal();
|
||||
const setCustomFieldValues = useSetCustomFieldValues();
|
||||
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 pipelines = pipelinesData?.data ?? [];
|
||||
|
||||
|
|
@ -269,11 +280,26 @@ export function DealFormModal({
|
|||
...(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) {
|
||||
updateMutation.mutate(
|
||||
{ id: deal.id, data: payload },
|
||||
{
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: () => {
|
||||
saveCustomFields(deal.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -284,7 +310,10 @@ export function DealFormModal({
|
|||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => onSuccess(),
|
||||
onSuccess: (res) => {
|
||||
if (res?.data?.id) saveCustomFields(res.data.id);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
|
|
@ -643,7 +672,7 @@ export function DealFormModal({
|
|||
)}
|
||||
|
||||
{/* Notizen */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Notizen</label>
|
||||
<textarea
|
||||
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
|
||||
|
|
@ -652,6 +681,16 @@ export function DealFormModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFields.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<CustomFieldsForm
|
||||
fields={customFields}
|
||||
onChange={handleCustomFieldsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
lexwareVouchersApi,
|
||||
tradeEventsApi,
|
||||
ownersApi,
|
||||
customFieldsApi,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -48,6 +49,10 @@ import type {
|
|||
CreateTradeEventPayload,
|
||||
UpdateTradeEventPayload,
|
||||
AddOwnerPayload,
|
||||
CustomFieldEntityType,
|
||||
CreateCustomFieldDefPayload,
|
||||
UpdateCustomFieldDefPayload,
|
||||
SetCustomFieldValuesPayload,
|
||||
} from './types';
|
||||
|
||||
// --- Query Key Factory ---
|
||||
|
|
@ -111,6 +116,13 @@ export const crmKeys = {
|
|||
active: () => ['crm', 'tradeEvents', 'active'] 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: {
|
||||
all: ['crm', 'lexware'] as const,
|
||||
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,
|
||||
useUpdateRelationshipType,
|
||||
useDeleteRelationshipType,
|
||||
useCustomFieldDefs,
|
||||
useCreateCustomFieldDef,
|
||||
useUpdateCustomFieldDef,
|
||||
useDeleteCustomFieldDef,
|
||||
} from '../hooks';
|
||||
import type {
|
||||
Industry,
|
||||
AccountType,
|
||||
RelationshipType,
|
||||
CustomFieldDef,
|
||||
CustomFieldEntityType,
|
||||
CustomFieldType,
|
||||
CustomFieldOption,
|
||||
} from '../types';
|
||||
import {
|
||||
CUSTOM_FIELD_ENTITY_TYPE_LABELS,
|
||||
CUSTOM_FIELD_TYPE_LABELS,
|
||||
} from '../types';
|
||||
import { LexwareSyncContent } from '../lexware/LexwareSyncPage';
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
type SettingsTab = 'module' | 'lexware' | 'settings';
|
||||
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'settings';
|
||||
|
||||
export function CrmSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
|
|
@ -741,6 +1244,25 @@ export function CrmSettingsPage() {
|
|||
</svg>
|
||||
Module
|
||||
</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
|
||||
className={`${styles.settingsTab} ${activeTab === 'lexware' ? styles.settingsTabActive : ''}`}
|
||||
onClick={() => setActiveTab('lexware')}
|
||||
|
|
@ -879,6 +1401,11 @@ export function CrmSettingsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Custom Fields */}
|
||||
{activeTab === 'customfields' && (
|
||||
<CustomFieldsConfig />
|
||||
)}
|
||||
|
||||
{/* Tab: Lexoffice Sync */}
|
||||
{activeTab === 'lexware' && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ export interface Contact {
|
|||
phones?: ContactPhone[];
|
||||
owners?: EntityOwner[];
|
||||
activities?: Activity[];
|
||||
customFields?: CustomFieldValue[];
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -410,6 +411,7 @@ export interface Deal {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owners?: EntityOwner[];
|
||||
customFields?: CustomFieldValue[];
|
||||
pipeline?: { id: string; name: string; stages?: PipelineStage[] };
|
||||
stage?: { id: string; name: string; color: string };
|
||||
contact?: {
|
||||
|
|
@ -483,6 +485,7 @@ export interface Company {
|
|||
emails?: ContactEmail[];
|
||||
phones?: ContactPhone[];
|
||||
owners?: EntityOwner[];
|
||||
customFields?: CustomFieldValue[];
|
||||
industryRef?: Industry | null;
|
||||
accountType?: AccountType | null;
|
||||
_count?: { contacts: number; deals: number; lexwareVouchers?: number; contracts?: number };
|
||||
|
|
@ -734,3 +737,85 @@ export interface UpdateTradeEventPayload {
|
|||
isActive?: boolean;
|
||||
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: {
|
||||
port: 8080,
|
||||
host: true,
|
||||
allowedHosts: ['.xinion.lan'],
|
||||
proxy: {
|
||||
'/api/v1/crm': {
|
||||
target: 'http://localhost:3100',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue