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:
Thomas Reitz 2026-03-12 18:22:57 +01:00
parent 405ab5f038
commit aaedf68085
30 changed files with 2645 additions and 34 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)
// --------------------------------------------------------

View file

@ -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;

View file

@ -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: [
{

View file

@ -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],

View file

@ -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 } });
}
}

View file

@ -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],

View file

@ -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 } });
}
}

View file

@ -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 };
}
}

View file

@ -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 {}

View 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;
}
}
}
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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],

View file

@ -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 } });
}
}

View 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>
);
}

View 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,
}}
>
&times;
</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>
);
}

View file

@ -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),
};

View file

@ -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}>

View file

@ -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={{

View file

@ -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 */}

View file

@ -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>

View file

@ -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) */}

View file

@ -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={{

View file

@ -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 });
},
});
}

View file

@ -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))}>
&times;
</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) &middot; 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))}>
&times;
</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' }}>&mdash;</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' && (
<>

View file

@ -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;
}>;
}

View file

@ -12,6 +12,7 @@ export default defineConfig({
server: {
port: 8080,
host: true,
allowedHosts: ['.xinion.lan'],
proxy: {
'/api/v1/crm': {
target: 'http://localhost:3100',