From b59289fc6eb578ad7af421785768220b73fe57e0 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 21 Aug 2026 13:43:29 +0200 Subject: [PATCH] feat(UI-Overhaul-Phase6): Tags Umstrukturierung 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 --- alembic/versions/0138_tags_tree_structure.py | 42 +++++++++ app/plugins/builtins/tags/models.py | 8 +- app/plugins/builtins/tags/routes.py | 25 ++++- app/plugins/builtins/tags/schemas.py | 9 ++ frontend/src/api/tags.ts | 9 ++ frontend/src/pages/Tags.tsx | 98 +++++++++++++++++++- frontend/src/routes/index.tsx | 2 +- 7 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/0138_tags_tree_structure.py diff --git a/alembic/versions/0138_tags_tree_structure.py b/alembic/versions/0138_tags_tree_structure.py new file mode 100644 index 0000000..c240c2a --- /dev/null +++ b/alembic/versions/0138_tags_tree_structure.py @@ -0,0 +1,42 @@ +"""Tags: parent_id, applicable_to, icon columns + +Revision ID: 0138 +Revises: 0137 +Create Date: 2026-08-21 + +Adds parent_id for tree structure, applicable_to for entity-type filtering, +and icon for per-tag icon selection. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB + +# revision identifiers, used by Alembic. +revision = "0138" +down_revision = "0137" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # parent_id for tree structure (self-referencing FK) + op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True)) + op.create_foreign_key( + "fk_tags_parent_id", "tags", "tags", ["parent_id"], ["id"], ondelete="SET NULL" + ) + op.create_index("ix_tags_parent", "tags", ["parent_id"]) + + # applicable_to: list of entity types where this tag can be applied + op.add_column("tags", sa.Column("applicable_to", JSONB, nullable=True)) + + # icon: icon name for frontend display + op.add_column("tags", sa.Column("icon", sa.String(50), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tags", "icon") + op.drop_column("tags", "applicable_to") + op.drop_index("ix_tags_parent", table_name="tags") + op.drop_constraint("fk_tags_parent_id", "tags", type_="foreignkey") + op.drop_column("tags", "parent_id") diff --git a/app/plugins/builtins/tags/models.py b/app/plugins/builtins/tags/models.py index 520c3dc..9a28104 100644 --- a/app/plugins/builtins/tags/models.py +++ b/app/plugins/builtins/tags/models.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from sqlalchemy import ForeignKey, Index, String, UniqueConstraint -from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin @@ -24,6 +24,7 @@ class Tag(Base, TenantMixin, OwnedMixin): __table_args__ = ( UniqueConstraint("tenant_id", "name", name="uq_tags_tenant_name"), Index("ix_tags_tenant", "tenant_id"), + Index("ix_tags_parent", "parent_id"), ) id: Mapped[uuid.UUID] = mapped_column( @@ -31,6 +32,11 @@ class Tag(Base, TenantMixin, OwnedMixin): ) name: Mapped[str] = mapped_column(String(100), nullable=False) color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280") + parent_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("tags.id", ondelete="SET NULL"), nullable=True + ) + applicable_to: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + icon: Mapped[str | None] = mapped_column(String(50), nullable=True) class TagAssignment(Base, TenantMixin, OwnedMixin): diff --git a/app/plugins/builtins/tags/routes.py b/app/plugins/builtins/tags/routes.py index 2698de9..10fc7a4 100644 --- a/app/plugins/builtins/tags/routes.py +++ b/app/plugins/builtins/tags/routes.py @@ -78,6 +78,9 @@ async def list_tags( "name": tag.name, "color": tag.color, "entity_count": count, + "parent_id": str(tag.parent_id) if tag.parent_id else None, + "applicable_to": tag.applicable_to, + "icon": tag.icon, } for tag, count in rows ] @@ -102,7 +105,15 @@ async def create_tag( from app.core.hooks import do_action await do_action("tag.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id) - tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color, owner_id=user_id) + tag = Tag( + tenant_id=tenant_id, + name=body.name, + color=body.color, + owner_id=user_id, + parent_id=uuid.UUID(body.parent_id) if body.parent_id else None, + applicable_to=body.applicable_to, + icon=body.icon, + ) db.add(tag) await db.flush() from app.core.hooks import do_action @@ -112,6 +123,9 @@ async def create_tag( "name": tag.name, "color": tag.color, "entity_count": 0, + "parent_id": str(tag.parent_id) if tag.parent_id else None, + "applicable_to": tag.applicable_to, + "icon": tag.icon, } @@ -145,6 +159,12 @@ async def update_tag( tag.name = data["name"] if "color" in data: tag.color = data["color"] + if "parent_id" in data: + tag.parent_id = uuid.UUID(data["parent_id"]) if data["parent_id"] else None + if "applicable_to" in data: + tag.applicable_to = data["applicable_to"] + if "icon" in data: + tag.icon = data["icon"] await db.flush() return { @@ -152,6 +172,9 @@ async def update_tag( "name": tag.name, "color": tag.color, "entity_count": 0, + "parent_id": str(tag.parent_id) if tag.parent_id else None, + "applicable_to": tag.applicable_to, + "icon": tag.icon, } diff --git a/app/plugins/builtins/tags/schemas.py b/app/plugins/builtins/tags/schemas.py index 2e0b8f7..f0d4842 100644 --- a/app/plugins/builtins/tags/schemas.py +++ b/app/plugins/builtins/tags/schemas.py @@ -8,11 +8,17 @@ from pydantic import BaseModel, Field class TagCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) color: str = Field("#6B7280", max_length=7) + parent_id: str | None = None + applicable_to: list[str] | None = None + icon: str | None = None class TagUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=100) color: str | None = Field(None, max_length=7) + parent_id: str | None = None + applicable_to: list[str] | None = None + icon: str | None = None class TagResponse(BaseModel): @@ -20,6 +26,9 @@ class TagResponse(BaseModel): name: str color: str entity_count: int = 0 + parent_id: str | None = None + applicable_to: list[str] | None = None + icon: str | None = None class TagAssignRequest(BaseModel): diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 3bfd69b..3f0a5d6 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -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 { diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 071ce48..ff011a7 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -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(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 ( + {/* Parent Tag */} +
+ + +
+ + {/* Icon */} +
+ +
+ {ICON_OPTIONS.map((iconName) => ( + + ))} +
+
+ + {/* Applicable To */} +
+ +
+ {ENTITY_TYPES.map((entityType) => ( + + ))} +
+
+ {/* Error */} {error && (
@@ -475,6 +568,7 @@ export function TagsPage() { open={showFormModal} onClose={handleFormClose} tag={editingTag} + tags={tags} onSubmit={handleFormSubmit} isSubmitting={isSubmitting} error={formError} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 5c6dcae..ae7b13d 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -259,7 +259,7 @@ const router = createBrowserRouter([ { path: '/workflows', element: {withSuspense()} }, { path: '/contacts/dedup', element: {withSuspense()} }, { path: '/import-export', element: {withSuspense()} }, - { path: '/tags', element: {withSuspense()} }, + { path: 'tags', element: {withSuspense()} }, { path: '/api-docs', element: {withSuspense()} }, { path: '/activity', element: {withSuspense()} }, { path: '/wiki', element: withSuspense() },