522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
// 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<string, string> = {
|
|
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<CustomFieldEntity>('contact');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [form, setForm] = useState<FormState>(emptyForm());
|
|
const [deleteTarget, setDeleteTarget] = useState<CustomFieldDefinition | null>(null);
|
|
const [formError, setFormError] = useState<string | null>(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<CustomFieldDefinition>[] = [
|
|
{
|
|
key: 'label',
|
|
header: t('customFields.columnLabel', 'Bezeichnung'),
|
|
sortable: true,
|
|
render: (row) => (
|
|
<span className="font-medium text-secondary-900">{row.label}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: t('customFields.columnName', 'Feldname'),
|
|
render: (row) => <code className="text-xs text-secondary-600">{row.name}</code>,
|
|
},
|
|
{
|
|
key: 'field_type',
|
|
header: t('customFields.columnType', 'Typ'),
|
|
render: (row) => {
|
|
const variantMap: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'secondary'> = {
|
|
text: 'secondary',
|
|
number: 'info',
|
|
date: 'warning',
|
|
select: 'primary',
|
|
multiselect: 'primary',
|
|
boolean: 'success',
|
|
};
|
|
return (
|
|
<Badge variant={variantMap[row.field_type] || 'secondary'}>
|
|
{FIELD_TYPE_LABELS[row.field_type] || row.field_type}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'required',
|
|
header: t('customFields.columnRequired', 'Pflicht'),
|
|
render: (row) =>
|
|
row.required ? (
|
|
<Badge variant="danger">{t('common.required', 'Pflichtfeld')}</Badge>
|
|
) : (
|
|
<span className="text-secondary-400">—</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'is_active',
|
|
header: t('customFields.columnStatus', 'Status'),
|
|
render: (row) =>
|
|
row.is_active ? (
|
|
<Badge variant="success" dot>{t('common.active', 'Aktiv')}</Badge>
|
|
) : (
|
|
<Badge variant="default">{t('common.inactive', 'Inaktiv')}</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (row) => (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => openEdit(row)}
|
|
className="p-1 rounded hover:bg-secondary-100"
|
|
aria-label={t('common.edit', 'Bearbeiten')}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteTarget(row)}
|
|
className="p-1 rounded hover:bg-danger-50 text-danger-600"
|
|
aria-label={t('common.delete', 'Löschen')}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// --- Render ---
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header with entity selector */}
|
|
<Card
|
|
title={t('customFields.title', 'Custom Fields')}
|
|
description={t('customFields.description', 'Verwalten Sie benutzerdefinierte Felder für Kontakte und Firmen')}
|
|
actions={
|
|
<Button size="sm" onClick={openCreate}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('customFields.addNew', 'Neues Feld')}
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="mb-4">
|
|
<Select
|
|
label={t('customFields.entity', 'Entität')}
|
|
options={ENTITY_OPTIONS}
|
|
value={entity}
|
|
onChange={(e) => setEntity(e.target.value as CustomFieldEntity)}
|
|
className="max-w-xs"
|
|
/>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
data={definitions}
|
|
rowKey={(row) => row.id}
|
|
loading={isLoading}
|
|
emptyMessage={t('customFields.empty', 'Noch keine Felder definiert')}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Create / Edit Modal */}
|
|
<Modal
|
|
open={showModal}
|
|
onClose={closeFormModal}
|
|
title={editingId ? t('customFields.editTitle', 'Feld bearbeiten') : t('customFields.createTitle', 'Neues Feld')}
|
|
size="lg"
|
|
>
|
|
<div className="space-y-4">
|
|
{formError && (
|
|
<div className="p-3 rounded-md bg-danger-50 border border-danger-200 text-danger-700 text-sm">
|
|
{formError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Label */}
|
|
<Input
|
|
label={t('customFields.fieldLabel', 'Bezeichnung')}
|
|
required
|
|
value={form.label}
|
|
onChange={(e) => handleLabelChange(e.target.value)}
|
|
placeholder="z.B. Branche, Abteilung, Geburtsdatum"
|
|
/>
|
|
|
|
{/* Name (auto-generated) */}
|
|
<Input
|
|
label={t('customFields.fieldName', 'Feldname (intern)')}
|
|
required
|
|
value={form.name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
helperText="Wird automatisch aus der Bezeichnung generiert"
|
|
placeholder="z.B. branche, abteilung, geburtsdatum"
|
|
/>
|
|
|
|
{/* Field type */}
|
|
<Select
|
|
label={t('customFields.fieldType', 'Feldtyp')}
|
|
required
|
|
options={FIELD_TYPE_OPTIONS}
|
|
value={form.field_type}
|
|
onChange={(e) => handleFieldTypeChange(e.target.value)}
|
|
/>
|
|
|
|
{/* Options (only for select / multiselect) */}
|
|
{needsOptions && (
|
|
<Input
|
|
label={t('customFields.options', 'Optionen (kommagetrennt)')}
|
|
required
|
|
value={form.optionsStr}
|
|
onChange={(e) => 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' && (
|
|
<Input
|
|
label={t('customFields.defaultValue', 'Standardwert')}
|
|
type={form.field_type === 'number' ? 'number' : form.field_type === 'date' ? 'date' : 'text'}
|
|
value={form.default_value}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, default_value: e.target.value }))}
|
|
helperText="Optional — wird für neue Einträge vorbelegt"
|
|
/>
|
|
)}
|
|
|
|
{form.field_type === 'boolean' && (
|
|
<Select
|
|
label={t('customFields.defaultValue', 'Standardwert')}
|
|
options={[
|
|
{ value: '', label: '— Keiner —' },
|
|
{ value: 'true', label: 'Ja / Aktiv' },
|
|
{ value: 'false', label: 'Nein / Inaktiv' },
|
|
]}
|
|
value={form.default_value}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, default_value: e.target.value }))}
|
|
/>
|
|
)}
|
|
|
|
{/* Required checkbox */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="cf-required"
|
|
type="checkbox"
|
|
checked={form.required}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, required: e.target.checked }))}
|
|
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<label htmlFor="cf-required" className="text-sm font-medium text-secondary-700 cursor-pointer">
|
|
{t('customFields.required', 'Pflichtfeld')}
|
|
</label>
|
|
</div>
|
|
|
|
{/* Active checkbox */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="cf-active"
|
|
type="checkbox"
|
|
checked={form.is_active}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))}
|
|
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<label htmlFor="cf-active" className="text-sm font-medium text-secondary-700 cursor-pointer">
|
|
{t('customFields.active', 'Aktiv')}
|
|
</label>
|
|
</div>
|
|
|
|
{/* Form actions */}
|
|
<div className="flex justify-end gap-2 pt-4 border-t border-secondary-200">
|
|
<Button variant="secondary" onClick={closeFormModal}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button onClick={handleSave} isLoading={saving}>
|
|
{editingId ? t('common.save', 'Speichern') : t('common.create', 'Erstellen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Delete confirmation modal */}
|
|
<Modal
|
|
open={!!deleteTarget}
|
|
onClose={() => setDeleteTarget(null)}
|
|
title={t('customFields.deleteTitle', 'Feld löschen')}
|
|
size="sm"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-secondary-700">
|
|
{t('customFields.deleteConfirm', 'Möchten Sie das Feld')}{' '}
|
|
<strong>{deleteTarget?.label}</strong>{' '}
|
|
{t('customFields.deleteConfirmEnd', 'wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')}
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button variant="danger" onClick={confirmDelete} isLoading={deleteMutation.isPending}>
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
{t('common.delete', 'Löschen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|