feat(UI-Overhaul-Phase6): Tags Umstrukturierung
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:
Agent Zero
2026-08-21 13:43:29 +02:00
parent 9f89cb17a0
commit b59289fc6e
7 changed files with 188 additions and 5 deletions
+9
View File
@@ -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 {
+96 -2
View File
@@ -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}
+1 -1
View File
@@ -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 />) },