Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation) - Custom Fields Backend: model, schema, service, routes, migration 0041 - Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering) - Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions - Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem - NotificationBell integrated into TopBar - Routes: /tags, /settings/custom-fields registered - Settings nav: Custom Fields entry added - Menu items: Tags added to automation plugin manifest
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Custom Fields Settings page.
|
||||
* Manage custom field definitions per entity (contact/company).
|
||||
*/
|
||||
|
||||
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: any) {
|
||||
setFormError(err.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: any) {
|
||||
setFormError(err.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: any) {
|
||||
toast.error(err.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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function SettingsPage() {
|
||||
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
||||
{ to: '/settings/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' },
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
|
||||
];
|
||||
|
||||
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Tags page — Tag management.
|
||||
*
|
||||
* Features:
|
||||
* - Table listing all tags with color dot, name, description, usage_count
|
||||
* - "+ Neuer Tag" button opens a create modal
|
||||
* - Edit modal for existing tags (name, color, description)
|
||||
* - Delete with confirmation dialog
|
||||
* - Color picker: predefined colors as clickable circles
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { Plus, Pencil, Trash2, AlertTriangle, Tag as TagIcon } from 'lucide-react';
|
||||
import { fetchTags, createTag, updateTag, deleteTag, type Tag, type CreateTagPayload, type UpdateTagPayload } from '@/api/tags';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { getTagColorClasses, TAG_COLOR_NAMES } from '@/components/tags/TagBadge';
|
||||
|
||||
// ─── Tag Form Modal (shared for create & edit) ──────────────────────────────
|
||||
|
||||
interface TagFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tag?: Tag | null;
|
||||
onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void;
|
||||
isSubmitting: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: TagFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!tag;
|
||||
|
||||
const [name, setName] = useState(tag?.name ?? '');
|
||||
const [color, setColor] = useState(tag?.color ?? 'blue');
|
||||
const [description, setDescription] = useState(tag?.description ?? '');
|
||||
|
||||
// Reset form when modal opens or tag changes
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName(tag?.name ?? '');
|
||||
setColor(tag?.color ?? 'blue');
|
||||
setDescription(tag?.description ?? '');
|
||||
}
|
||||
}, [open, tag]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
const data: CreateTagPayload | UpdateTagPayload = {
|
||||
name: trimmedName,
|
||||
color,
|
||||
description: description.trim() || null,
|
||||
};
|
||||
onSubmit(data);
|
||||
},
|
||||
[name, color, description, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t('tags.editTitle', 'Tag bearbeiten') : t('tags.createTitle', 'Neuer Tag')}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<Input
|
||||
label={t('tags.name', 'Name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder={t('tags.namePlaceholder', 'z.B. Wichtig')}
|
||||
autoFocus
|
||||
data-testid="tag-form-name"
|
||||
/>
|
||||
|
||||
{/* Color picker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
|
||||
{t('tags.color', 'Farbe')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{TAG_COLOR_NAMES.map((colorName) => {
|
||||
const colors = getTagColorClasses(colorName);
|
||||
return (
|
||||
<button
|
||||
key={colorName}
|
||||
type="button"
|
||||
onClick={() => setColor(colorName)}
|
||||
className={clsx(
|
||||
'rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-500',
|
||||
colors.dot,
|
||||
'w-8 h-8',
|
||||
color === colorName
|
||||
? 'ring-2 ring-offset-2 ring-secondary-400 scale-110'
|
||||
: 'hover:scale-110'
|
||||
)}
|
||||
aria-label={colorName}
|
||||
aria-pressed={color === colorName}
|
||||
data-testid={`color-option-${colorName}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('tags.description', 'Beschreibung')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('tags.descriptionPlaceholder', 'Optionale Beschreibung')}
|
||||
rows={3}
|
||||
className={clsx(
|
||||
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
'motion-safe:transition-colors text-secondary-900 placeholder-secondary-400'
|
||||
)}
|
||||
data-testid="tag-form-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!name.trim()}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
data-testid="tag-form-submit"
|
||||
>
|
||||
{isEdit ? t('common.save', 'Speichern') : t('tags.create', 'Erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
|
||||
|
||||
interface DeleteModalProps {
|
||||
open: boolean;
|
||||
tag: Tag | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteModal({ open, tag, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={t('tags.deleteTitle', 'Tag löschen')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-secondary-700">
|
||||
{t('tags.deleteConfirm', 'Möchten Sie den Tag')}{' '}
|
||||
<span className="font-semibold text-secondary-900">{tag?.name}</span>{' '}
|
||||
{t('tags.deleteConfirmEnd', 'wirklich löschen?')}
|
||||
</p>
|
||||
{tag && tag.usage_count !== undefined && tag.usage_count > 0 && (
|
||||
<p className="text-sm text-secondary-500 mt-1">
|
||||
{t('tags.deleteUsageWarning', 'Dieser Tag wird von')}{' '}
|
||||
<span className="font-semibold">{tag.usage_count}</span>{' '}
|
||||
{t('tags.deleteUsageEntities', 'Element(en) verwendet.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
isLoading={isDeleting}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
data-testid="tag-delete-confirm"
|
||||
>
|
||||
{t('common.delete', 'Löschen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tags Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function TagsPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTag, setDeletingTag] = useState<Tag | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────
|
||||
const { data: tags = [], isLoading } = useQuery<Tag[]>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: fetchTags,
|
||||
});
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const createMutation = useMutation<Tag, Error, CreateTagPayload>({
|
||||
mutationFn: createTag,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
setShowFormModal(false);
|
||||
setFormError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setFormError(err.message || t('tags.createError', 'Fehler beim Erstellen.'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation<Tag, Error, { id: string; payload: UpdateTagPayload }>({
|
||||
mutationFn: ({ id, payload }) => updateTag(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
setShowFormModal(false);
|
||||
setEditingTag(null);
|
||||
setFormError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setFormError(err.message || t('tags.updateError', 'Fehler beim Speichern.'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation<void, Error, string>({
|
||||
mutationFn: deleteTag,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
setShowDeleteModal(false);
|
||||
setDeletingTag(null);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleCreateClick = useCallback(() => {
|
||||
setEditingTag(null);
|
||||
setFormError(null);
|
||||
setShowFormModal(true);
|
||||
}, []);
|
||||
|
||||
const handleEditClick = useCallback((tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
setFormError(null);
|
||||
setShowFormModal(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteClick = useCallback((tag: Tag) => {
|
||||
setDeletingTag(tag);
|
||||
setShowDeleteModal(true);
|
||||
}, []);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(data: CreateTagPayload | UpdateTagPayload) => {
|
||||
setFormError(null);
|
||||
if (editingTag) {
|
||||
updateMutation.mutate({ id: editingTag.id, payload: data as UpdateTagPayload });
|
||||
} else {
|
||||
createMutation.mutate(data as CreateTagPayload);
|
||||
}
|
||||
},
|
||||
[editingTag, createMutation, updateMutation]
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (deletingTag) {
|
||||
deleteMutation.mutate(deletingTag.id);
|
||||
}
|
||||
}, [deletingTag, deleteMutation]);
|
||||
|
||||
const handleFormClose = useCallback(() => {
|
||||
setShowFormModal(false);
|
||||
setEditingTag(null);
|
||||
setFormError(null);
|
||||
}, []);
|
||||
|
||||
const handleDeleteCancel = useCallback(() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingTag(null);
|
||||
}, []);
|
||||
|
||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-4" data-testid="tags-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-secondary-900">
|
||||
{t('tags.title', 'Tags')}
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">
|
||||
{t('tags.subtitle', 'Verwalten Sie Tags für Kontakte, Dateien und Termine')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
onClick={handleCreateClick}
|
||||
data-testid="tag-create-btn"
|
||||
>
|
||||
{t('tags.newTag', 'Neuer Tag')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tags table */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 text-secondary-500">
|
||||
<TagIcon className="h-5 w-5 animate-pulse" aria-hidden="true" />
|
||||
{t('common.loading', 'Laden...')}
|
||||
</div>
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-3 w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center">
|
||||
<TagIcon className="h-6 w-6 text-secondary-400" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 mb-3">
|
||||
{t('tags.empty', 'Noch keine Tags vorhanden.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
{t('tags.createFirst', 'Ersten Tag erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" data-testid="tags-table">
|
||||
<thead>
|
||||
<tr className="border-b border-secondary-200">
|
||||
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('tags.color', 'Farbe')}
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('tags.name', 'Name')}
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('tags.description', 'Beschreibung')}
|
||||
</th>
|
||||
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('tags.usage', 'Verwendung')}
|
||||
</th>
|
||||
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('common.actions', 'Aktionen')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{tags.map((tag) => {
|
||||
const colors = getTagColorClasses(tag.color);
|
||||
return (
|
||||
<tr
|
||||
key={tag.id}
|
||||
className="hover:bg-secondary-50 transition-colors"
|
||||
data-testid={`tag-row-${tag.id}`}
|
||||
>
|
||||
{/* Color dot */}
|
||||
<td className="px-3 py-3">
|
||||
<span
|
||||
className={clsx('inline-block rounded-full', colors.dot, 'w-3.5 h-3.5')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</td>
|
||||
{/* Name */}
|
||||
<td className="px-3 py-3">
|
||||
<span className="text-sm font-medium text-secondary-900">{tag.name}</span>
|
||||
</td>
|
||||
{/* Description */}
|
||||
<td className="px-3 py-3 max-w-xs">
|
||||
<span className="text-sm text-secondary-600 truncate block">
|
||||
{tag.description || '—'}
|
||||
</span>
|
||||
</td>
|
||||
{/* Usage count */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
tag.usage_count && tag.usage_count > 0
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'bg-secondary-100 text-secondary-500'
|
||||
)}
|
||||
>
|
||||
{tag.usage_count ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditClick(tag)}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md p-1.5',
|
||||
'text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
'min-h-touch min-w-touch'
|
||||
)}
|
||||
aria-label={t('tags.editLabel', 'Tag bearbeiten')}
|
||||
data-testid={`tag-edit-btn-${tag.id}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteClick(tag)}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md p-1.5',
|
||||
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500',
|
||||
'min-h-touch min-w-touch'
|
||||
)}
|
||||
aria-label={t('tags.deleteLabel', 'Tag löschen')}
|
||||
data-testid={`tag-delete-btn-${tag.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<TagFormModal
|
||||
open={showFormModal}
|
||||
onClose={handleFormClose}
|
||||
tag={editingTag}
|
||||
onSubmit={handleFormSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={formError}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteModal
|
||||
open={showDeleteModal}
|
||||
tag={deletingTag}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteMutation.isError && (
|
||||
<div className="fixed bottom-4 right-4 rounded-md bg-danger-600 text-white px-4 py-3 shadow-lg text-sm z-50">
|
||||
{deleteMutation.error?.message || t('tags.deleteError', 'Fehler beim Löschen.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user