// TODO: P2-T21 — Replace hardcoded ENTITY_OPTIONS with backend config /** * Custom Fields Settings page. * Manage custom field definitions per entity (contact/company). */ import { asError } from '@/utils/errorTypes'; import React, { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Select } from '@/components/ui/Select'; import { Badge } from '@/components/ui/Badge'; import { Modal } from '@/components/ui/Modal'; import { Table, TableColumn } from '@/components/ui/Table'; import { useToast } from '@/components/ui/Toast'; import { Plus, Pencil, Trash2 } from 'lucide-react'; import { CustomFieldDefinition, CustomFieldDefinitionCreate, CustomFieldDefinitionUpdate, CustomFieldType, CustomFieldEntity, useCustomFieldDefinitions, useCreateCustomFieldDefinition, useUpdateCustomFieldDefinition, useDeleteCustomFieldDefinition, } from '@/api/customFieldDefinitions'; // --- Helpers --- const FIELD_TYPE_OPTIONS = [ { value: 'text', label: 'Text' }, { value: 'number', label: 'Zahl' }, { value: 'date', label: 'Datum' }, { value: 'select', label: 'Auswahl (einfach)' }, { value: 'multiselect', label: 'Auswahl (mehrfach)' }, { value: 'boolean', label: 'Checkbox (Ja/Nein)' }, ]; const ENTITY_OPTIONS = [ { value: 'contact', label: 'Kontakt' }, { value: 'company', label: 'Firma' }, ]; const FIELD_TYPE_LABELS: Record = { text: 'Text', number: 'Zahl', date: 'Datum', select: 'Auswahl', multiselect: 'Mehrfachauswahl', boolean: 'Checkbox', }; function slugifyLabel(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, ''); } function parseOptionsString(optionsStr: string): string[] { return optionsStr .split(',') .map((o) => o.trim()) .filter((o) => o.length > 0); } function optionsToString(options: string[] | undefined): string { return (options || []).join(', '); } // --- Form state interface --- interface FormState { label: string; name: string; nameTouched: boolean; field_type: CustomFieldType; optionsStr: string; required: boolean; default_value: string; is_active: boolean; } function emptyForm(): FormState { return { label: '', name: '', nameTouched: false, field_type: 'text', optionsStr: '', required: false, default_value: '', is_active: true, }; } function formFromDefinition(def: CustomFieldDefinition): FormState { return { label: def.label, name: def.name, nameTouched: true, field_type: def.field_type, optionsStr: optionsToString(def.options), required: def.required, default_value: def.default_value != null ? String(def.default_value) : '', is_active: def.is_active, }; } // --- Page component --- export function CustomFieldsPage() { const { t } = useTranslation(); const toast = useToast(); const [entity, setEntity] = useState('contact'); const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm()); const [deleteTarget, setDeleteTarget] = useState(null); const [formError, setFormError] = useState(null); const { data, isLoading } = useCustomFieldDefinitions(entity); const createMutation = useCreateCustomFieldDefinition(); const updateMutation = useUpdateCustomFieldDefinition(); const deleteMutation = useDeleteCustomFieldDefinition(); const definitions = useMemo(() => data?.items ?? [], [data]); // Auto-generate name from label when name hasn't been manually edited useEffect(() => { if (!form.nameTouched && form.label) { setForm((prev) => ({ ...prev, name: slugifyLabel(prev.label) })); } }, [form.label, form.nameTouched]); const openCreate = () => { setForm(emptyForm()); setEditingId(null); setFormError(null); setShowModal(true); }; const openEdit = (def: CustomFieldDefinition) => { setForm(formFromDefinition(def)); setEditingId(def.id); setFormError(null); setShowModal(true); }; const closeFormModal = () => { setShowModal(false); setEditingId(null); setFormError(null); }; const handleNameChange = (val: string) => { setForm((prev) => ({ ...prev, name: val, nameTouched: true })); }; const handleLabelChange = (val: string) => { setForm((prev) => ({ ...prev, label: val })); }; const handleFieldTypeChange = (val: string) => { setForm((prev) => ({ ...prev, field_type: val as CustomFieldType, // Reset default_value and options when switching away from select/multiselect default_value: val === 'boolean' ? '' : prev.default_value, })); }; const handleSave = async () => { setFormError(null); if (!form.label.trim()) { setFormError('Bitte ein Label eingeben'); return; } if (!form.name.trim()) { setFormError('Bitte einen Feldnamen eingeben'); return; } const needsOptions = form.field_type === 'select' || form.field_type === 'multiselect'; const parsedOptions = needsOptions ? parseOptionsString(form.optionsStr) : []; if (needsOptions && parsedOptions.length === 0) { setFormError('Bitte mindestens eine Option eingeben (kommagetrennt)'); return; } // Convert default_value based on field_type let defaultValue: any = form.default_value; if (form.field_type === 'number') { defaultValue = form.default_value === '' ? null : Number(form.default_value); } else if (form.field_type === 'boolean') { defaultValue = form.default_value === 'true' || form.default_value === '1'; } else if (form.default_value === '') { defaultValue = null; } const isEditing = !!editingId; if (isEditing) { const updateData: CustomFieldDefinitionUpdate = { name: form.name.trim(), label: form.label.trim(), field_type: form.field_type, options: needsOptions ? parsedOptions : [], default_value: defaultValue, required: form.required, is_active: form.is_active, }; try { await updateMutation.mutateAsync({ id: editingId!, data: updateData }); toast.success(t('customFields.updated', 'Feld aktualisiert')); closeFormModal(); } catch (err: unknown) { const errObj = asError(err); setFormError(errObj.message || 'Fehler beim Aktualisieren'); } } else { const createData: CustomFieldDefinitionCreate = { entity, name: form.name.trim(), label: form.label.trim(), field_type: form.field_type, options: needsOptions ? parsedOptions : [], default_value: defaultValue, required: form.required, is_active: form.is_active, }; try { await createMutation.mutateAsync(createData); toast.success(t('customFields.created', 'Feld erstellt')); closeFormModal(); } catch (err: unknown) { const errObj = asError(err); setFormError(errObj.message || 'Fehler beim Erstellen'); } } }; const confirmDelete = async () => { if (!deleteTarget) return; try { await deleteMutation.mutateAsync(deleteTarget.id); toast.success(t('customFields.deleted', 'Feld gelöscht')); setDeleteTarget(null); } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || 'Fehler beim Löschen'); setDeleteTarget(null); } }; const needsOptions = form.field_type === 'select' || form.field_type === 'multiselect'; const saving = createMutation.isPending || updateMutation.isPending; // --- Table columns --- const columns: TableColumn[] = [ { key: 'label', header: t('customFields.columnLabel', 'Bezeichnung'), sortable: true, render: (row) => ( {row.label} ), }, { key: 'name', header: t('customFields.columnName', 'Feldname'), render: (row) => {row.name}, }, { key: 'field_type', header: t('customFields.columnType', 'Typ'), render: (row) => { const variantMap: Record = { text: 'secondary', number: 'info', date: 'warning', select: 'primary', multiselect: 'primary', boolean: 'success', }; return ( {FIELD_TYPE_LABELS[row.field_type] || row.field_type} ); }, }, { key: 'required', header: t('customFields.columnRequired', 'Pflicht'), render: (row) => row.required ? ( {t('common.required', 'Pflichtfeld')} ) : ( ), }, { key: 'is_active', header: t('customFields.columnStatus', 'Status'), render: (row) => row.is_active ? ( {t('common.active', 'Aktiv')} ) : ( {t('common.inactive', 'Inaktiv')} ), }, { key: 'actions', header: '', render: (row) => (
), }, ]; // --- Render --- return (
{/* Header with entity selector */} {t('customFields.addNew', 'Neues Feld')} } >
handleLabelChange(e.target.value)} placeholder="z.B. Branche, Abteilung, Geburtsdatum" /> {/* Name (auto-generated) */} handleNameChange(e.target.value)} helperText="Wird automatisch aus der Bezeichnung generiert" placeholder="z.B. branche, abteilung, geburtsdatum" /> {/* Field type */} setForm((prev) => ({ ...prev, optionsStr: e.target.value }))} placeholder="Option 1, Option 2, Option 3" helperText="Trennen Sie die Optionen mit Kommas" /> )} {/* Default value — varies by field type */} {form.field_type !== 'multiselect' && form.field_type !== 'boolean' && ( setForm((prev) => ({ ...prev, default_value: e.target.value }))} helperText="Optional — wird für neue Einträge vorbelegt" /> )} {form.field_type === 'boolean' && ( setForm((prev) => ({ ...prev, required: e.target.checked }))} className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" />
{/* Active checkbox */}
setForm((prev) => ({ ...prev, is_active: e.target.checked }))} className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" />
{/* Form actions */}
{/* Delete confirmation modal */} setDeleteTarget(null)} title={t('customFields.deleteTitle', 'Feld löschen')} size="sm" >

{t('customFields.deleteConfirm', 'Möchten Sie das Feld')}{' '} {deleteTarget?.label}{' '} {t('customFields.deleteConfirmEnd', 'wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')}

); }