feat(UI-Overhaul-Phase6): Tags Umstrukturierung
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Migration 0138: Add parent_id, applicable_to, icon columns to tags table Backend: - Tag model: add parent_id (self-FK), applicable_to (JSONB), icon (VARCHAR) - TagCreate/TagUpdate/TagResponse schemas: add new fields - Tags routes: create_tag, update_tag, list_tags return new fields Frontend: - api/tags.ts: Tag interface, CreateTagPayload, UpdateTagPayload updated with new fields - Tags route moved from /tags to /settings/tags (under Settings) - Tags.tsx: TagFormModal updated with parent tag selector, icon picker, applicable_to multi-select - TagsPage passes tags list to TagFormModal for parent selection tsc clean, backend import OK
This commit is contained in:
@@ -20,6 +20,9 @@ export interface Tag {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
usage_count?: number;
|
||||
parent_id?: string | null;
|
||||
applicable_to?: string[] | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface TagAssignment {
|
||||
@@ -43,12 +46,18 @@ export interface CreateTagPayload {
|
||||
name: string;
|
||||
color?: string;
|
||||
description?: string | null;
|
||||
parent_id?: string | null;
|
||||
applicable_to?: string[] | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateTagPayload {
|
||||
name?: string;
|
||||
color?: string;
|
||||
description?: string | null;
|
||||
parent_id?: string | null;
|
||||
applicable_to?: string[] | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface AssignTagPayload {
|
||||
|
||||
@@ -27,18 +27,25 @@ interface TagFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tag?: Tag | null;
|
||||
tags?: Tag[];
|
||||
onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void;
|
||||
isSubmitting: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: TagFormModalProps) {
|
||||
function TagFormModal({ open, onClose, tag, tags = [], 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 ?? '');
|
||||
const [parentId, setParentId] = useState(tag?.parent_id ?? '');
|
||||
const [icon, setIcon] = useState(tag?.icon ?? '');
|
||||
const [applicableTo, setApplicableTo] = useState<string[]>(tag?.applicable_to ?? []);
|
||||
|
||||
const ENTITY_TYPES = ['contact', 'file', 'calendar_entry', 'mail', 'task'];
|
||||
const ICON_OPTIONS = ['Tag', 'Star', 'Heart', 'Flag', 'Bookmark', 'Circle', 'Square', 'Hash', 'AlertCircle', 'CheckCircle'];
|
||||
|
||||
// Reset form when modal opens or tag changes
|
||||
React.useEffect(() => {
|
||||
@@ -46,6 +53,9 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
|
||||
setName(tag?.name ?? '');
|
||||
setColor(tag?.color ?? 'blue');
|
||||
setDescription(tag?.description ?? '');
|
||||
setParentId(tag?.parent_id ?? '');
|
||||
setIcon(tag?.icon ?? '');
|
||||
setApplicableTo(tag?.applicable_to ?? []);
|
||||
}
|
||||
}, [open, tag]);
|
||||
|
||||
@@ -58,12 +68,18 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
|
||||
name: trimmedName,
|
||||
color,
|
||||
description: description.trim() || null,
|
||||
parent_id: parentId || null,
|
||||
icon: icon || null,
|
||||
applicable_to: applicableTo.length > 0 ? applicableTo : null,
|
||||
};
|
||||
onSubmit(data);
|
||||
},
|
||||
[name, color, description, onSubmit]
|
||||
[name, color, description, parentId, icon, applicableTo, onSubmit]
|
||||
);
|
||||
|
||||
// Filter out self and descendants for parent selection
|
||||
const availableParents = tags.filter((t) => t.id !== tag?.id);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -132,6 +148,83 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parent Tag */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('tags.parentTag', 'Übergeordneter Tag')}
|
||||
</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="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"
|
||||
data-testid="tag-form-parent"
|
||||
>
|
||||
<option value="">{t('tags.noParent', 'Kein übergeordneter Tag')}</option>
|
||||
{availableParents.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('tags.icon', 'Symbol')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ICON_OPTIONS.map((iconName) => (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setIcon(iconName === icon ? '' : iconName)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded-md text-xs font-medium border transition-all',
|
||||
icon === iconName
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-secondary-200 text-secondary-600 hover:bg-secondary-50'
|
||||
)}
|
||||
aria-pressed={icon === iconName}
|
||||
>
|
||||
{iconName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicable To */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('tags.applicableTo', 'Anwendbar auf')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ENTITY_TYPES.map((entityType) => (
|
||||
<label
|
||||
key={entityType}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium border cursor-pointer transition-all',
|
||||
applicableTo.includes(entityType)
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-secondary-200 text-secondary-600 hover:bg-secondary-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applicableTo.includes(entityType)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setApplicableTo([...applicableTo, entityType]);
|
||||
} else {
|
||||
setApplicableTo(applicableTo.filter((t) => t !== entityType));
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{entityType}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
|
||||
@@ -475,6 +568,7 @@ export function TagsPage() {
|
||||
open={showFormModal}
|
||||
onClose={handleFormClose}
|
||||
tag={editingTag}
|
||||
tags={tags}
|
||||
onSubmit={handleFormSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={formError}
|
||||
|
||||
@@ -259,7 +259,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
|
||||
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> },
|
||||
{ path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
|
||||
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: 'tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||
{ path: '/wiki', element: withSuspense(<WikiPage />) },
|
||||
|
||||
Reference in New Issue
Block a user