feat: Kontakt-Ordner mit Drag&Drop Baum (wie KI-Chat Sidebar)

- Backend: ContactFolder model (hierarchisch, parent_id, sort_order)
- Migration 0022: contact_folders Tabelle + folder_id FK auf contacts
- CRUD Routes: /api/v1/contact-folders (list, create, update, delete, reorder)
- Move Contact API: /api/v1/contact-folders/contacts/{id}/move
- folder_id Filter in contacts list API
- Frontend: ContactFolderTree mit Baum-Struktur, Drag&Drop, Kontext-Menü
- Kontakt-Personen-Icon statt Ordner-Symbol
- ContactList Items draggable (List/Table/Cards View)
- React Query Hooks für Folder CRUD + Move Contact
- Alle 266 Frontend-Tests passing
This commit is contained in:
Agent Zero
2026-07-20 01:33:44 +02:00
parent 4bc11efc25
commit 29202325a6
17 changed files with 942 additions and 8 deletions
+46
View File
@@ -0,0 +1,46 @@
"""Contact folders — hierarchical folders for organizing contacts.
Revision ID: 0022
Revises: 0021_unified_contacts
Create Date: 2026-07-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0022_contact_folders"
down_revision = "0021_unified_contacts"
def upgrade():
# 1. Create contact_folders table
op.create_table(
"contact_folders",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("parent_id", UUID(as_uuid=True), sa.ForeignKey("contact_folders.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_contact_folders_tenant_parent", "contact_folders", ["tenant_id", "parent_id"])
op.create_index("ix_contact_folders_user", "contact_folders", ["user_id"])
# 2. Add folder_id column to contacts
op.add_column(
"contacts",
sa.Column("folder_id", UUID(as_uuid=True), sa.ForeignKey("contact_folders.id", ondelete="SET NULL"), nullable=True),
)
op.create_index("ix_contacts_folder_id", "contacts", ["folder_id"])
def downgrade():
op.drop_index("ix_contacts_folder_id", table_name="contacts")
op.drop_column("contacts", "folder_id")
op.drop_index("ix_contact_folders_user", table_name="contact_folders")
op.drop_index("ix_contact_folders_tenant_parent", table_name="contact_folders")
op.drop_table("contact_folders")
+2
View File
@@ -29,6 +29,7 @@ from app.routes import (
audit, audit,
auth, auth,
companies, companies,
contact_folders,
contacts, contacts,
groups, groups,
health, health,
@@ -230,6 +231,7 @@ def create_app() -> FastAPI:
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(companies.router) app.include_router(companies.router)
app.include_router(contacts.router) app.include_router(contacts.router)
app.include_router(contact_folders.router)
app.include_router(import_export.router) app.include_router(import_export.router)
app.include_router(plugins.router) app.include_router(plugins.router)
app.include_router(ai_copilot.router) app.include_router(ai_copilot.router)
+2
View File
@@ -7,6 +7,7 @@ from app.models.audit import AuditLog, DeletionLog
from app.models.auth import ApiToken, PasswordResetToken from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company from app.models.company import Company
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder
from app.models.currency import Currency from app.models.currency import Currency
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType from app.models.notification import Notification, NotificationPreference, NotificationType
@@ -38,6 +39,7 @@ __all__ = [
"Company", "Company",
"Contact", "Contact",
"ContactPerson", "ContactPerson",
"ContactFolder",
"Currency", "Currency",
"TaxRate", "TaxRate",
"Sequence", "Sequence",
+8
View File
@@ -142,6 +142,14 @@ class Contact(Base, TenantMixin):
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
) )
# ── Folder assignment ──
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Custom fields ── # ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict) custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
+57
View File
@@ -0,0 +1,57 @@
"""ContactFolder model — hierarchical folders for organizing contacts.
Supports nested folders via parent_id, per-user ownership, and sort_order
for manual ordering via drag & drop.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin
class ContactFolder(Base, TenantMixin):
"""Hierarchical folder for organizing contacts.
Folders are tenant-scoped and user-owned. A folder with parent_id=NULL
is a root folder. sort_order controls display order within the same parent.
"""
__tablename__ = "contact_folders"
__table_args__ = (
Index("ix_contact_folders_tenant_parent", "tenant_id", "parent_id"),
Index("ix_contact_folders_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="CASCADE"),
nullable=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# ── Relationships ──
parent: Mapped[ContactFolder | None] = relationship(
"ContactFolder",
remote_side="ContactFolder.id",
back_populates="children",
)
children: Mapped[list[ContactFolder]] = relationship(
"ContactFolder",
back_populates="parent",
cascade="all, delete-orphan",
order_by="ContactFolder.sort_order",
)
+109
View File
@@ -0,0 +1,109 @@
"""Contact folder routes — CRUD, move contacts, reorder."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.schemas.contact_folder import (
ContactFolderCreate,
ContactFolderUpdate,
MoveContactRequest,
)
from app.services import contact_folder_service
router = APIRouter(prefix="/api/v1/contact-folders", tags=["contact-folders"])
@router.get("")
async def list_folders(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""List all contact folders for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
return await contact_folder_service.list_folders(db, tenant_id, user_id)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_folder(
body: ContactFolderCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Create a new contact folder."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
return await contact_folder_service.create_folder(
db, tenant_id, user_id, body.name, body.parent_id
)
@router.put("/{folder_id}")
async def update_folder(
folder_id: str,
body: ContactFolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact folder (name, parent, sort_order)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try:
return await contact_folder_service.update_folder(
db, tenant_id, user_id, folder_id, data
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Delete a contact folder. Contacts are unassigned."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
await contact_folder_service.delete_folder(db, tenant_id, user_id, folder_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{folder_id}/reorder")
async def reorder_folders(
folder_id: str,
body: list[dict],
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Batch reorder folders. Body: [{id, sort_order, parent_id?}, ...]."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
await contact_folder_service.reorder_folders(db, tenant_id, user_id, body)
return {"ok": True}
@router.put("/contacts/{contact_id}/move")
async def move_contact(
contact_id: str,
body: MoveContactRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Move a contact to a folder (or unassign with folder_id=null)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
return await contact_folder_service.move_contact(
db, tenant_id, contact_id, body.folder_id
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+4 -2
View File
@@ -29,17 +29,19 @@ async def list_contacts(
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(None), search: str | None = Query(None),
type: str | None = Query(None, pattern="^(company|person)$"), type: str | None = Query(None, pattern="^(company|person)$"),
folder_id: str | None = Query(None),
sort_by: str = Query("displayname"), sort_by: str = Query("displayname"),
sort_order: str = Query("asc", pattern="^(asc|desc)$"), sort_order: str = Query("asc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), current_user: dict = Depends(require_permission("contacts:read")),
): ):
"""List contacts with pagination, FTS search, type filter, sorting.""" """List contacts with pagination, FTS search, type/folder filter, sorting."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
return await contact_service.list_contacts( return await contact_service.list_contacts(
db, tenant_id, db, tenant_id,
page=page, page_size=page_size, search=search, page=page, page_size=page_size, search=search,
contact_type=type, sort_by=sort_by, sort_order=sort_order, contact_type=type, folder_id=folder_id,
sort_by=sort_by, sort_order=sort_order,
resolved_perms=current_user, resolved_perms=current_user,
) )
+4
View File
@@ -141,6 +141,8 @@ class ContactCreate(BaseModel):
image: str | None = None image: str | None = None
# Custom # Custom
custom: dict | None = None custom: dict | None = None
# Folder assignment
folder_id: str | None = None
# Contact persons (optional inline create) # Contact persons (optional inline create)
contact_persons: list[ContactPersonCreate] | None = None contact_persons: list[ContactPersonCreate] | None = None
@@ -208,6 +210,7 @@ class ContactUpdate(BaseModel):
tags: str | None = Field(None, max_length=500) tags: str | None = Field(None, max_length=500)
image: str | None = None image: str | None = None
custom: dict | None = None custom: dict | None = None
folder_id: str | None = None
default_person_id: str | None = None default_person_id: str | None = None
admin_contactperson_id: str | None = None admin_contactperson_id: str | None = None
@@ -278,6 +281,7 @@ class ContactResponse(BaseModel):
tags: str | None = None tags: str | None = None
image: str | None = None image: str | None = None
custom: dict | None = None custom: dict | None = None
folder_id: str | None = None
default_person_id: str | None = None default_person_id: str | None = None
admin_contactperson_id: str | None = None admin_contactperson_id: str | None = None
created_at: str | None = None created_at: str | None = None
+33
View File
@@ -0,0 +1,33 @@
"""Contact folder schemas — CRUD for hierarchical contact folders."""
from __future__ import annotations
from pydantic import BaseModel, Field
class ContactFolderCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
parent_id: str | None = None
class ContactFolderUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
parent_id: str | None = None
sort_order: int | None = None
class ContactFolderResponse(BaseModel):
id: str
name: str
parent_id: str | None = None
user_id: str
sort_order: int = 0
class ContactFolderTreeResponse(ContactFolderResponse):
children: list["ContactFolderTreeResponse"] = Field(default_factory=list)
contact_count: int = 0
class MoveContactRequest(BaseModel):
folder_id: str | None = None
+194
View File
@@ -0,0 +1,194 @@
"""Contact folder service — CRUD, tree building, move contacts."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.contact import Contact
from app.models.contact_folder import ContactFolder
def _serialize_folder(f: ContactFolder, contact_count: int = 0) -> dict:
return {
"id": str(f.id),
"name": f.name,
"parent_id": str(f.parent_id) if f.parent_id else None,
"user_id": str(f.user_id),
"sort_order": f.sort_order,
"contact_count": contact_count,
}
async def list_folders(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> list[dict]:
"""List all folders for a user (flat list, frontend builds tree)."""
result = await db.execute(
select(ContactFolder)
.where(ContactFolder.tenant_id == tenant_id)
.where(ContactFolder.user_id == user_id)
.where(ContactFolder.deleted_at.is_(None))
.order_by(ContactFolder.parent_id, ContactFolder.sort_order, ContactFolder.name)
)
folders = result.scalars().all()
# Count contacts per folder
folder_ids = [f.id for f in folders]
counts: dict[uuid.UUID, int] = {}
if folder_ids:
count_result = await db.execute(
select(Contact.folder_id, func.count(Contact.id))
.where(Contact.folder_id.in_(folder_ids))
.where(Contact.deleted_at.is_(None))
.group_by(Contact.folder_id)
)
for fid, cnt in count_result:
counts[fid] = cnt
return [
{**_serialize_folder(f), "contact_count": counts.get(f.id, 0)}
for f in folders
]
async def create_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str,
parent_id: str | None = None,
) -> dict:
"""Create a new contact folder."""
parent_uuid = uuid.UUID(parent_id) if parent_id else None
# Determine sort_order: max + 1 among siblings
max_order_result = await db.execute(
select(func.max(ContactFolder.sort_order))
.where(ContactFolder.tenant_id == tenant_id)
.where(ContactFolder.user_id == user_id)
.where(ContactFolder.parent_id == parent_uuid if parent_uuid else ContactFolder.parent_id.is_(None))
)
max_order = max_order_result.scalar() or 0
folder = ContactFolder(
tenant_id=tenant_id,
user_id=user_id,
name=name,
parent_id=parent_uuid,
sort_order=max_order + 1,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return _serialize_folder(folder)
async def update_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
folder_id: str,
data: dict,
) -> dict:
"""Update a contact folder (name, parent_id, sort_order)."""
result = await db.execute(
select(ContactFolder)
.where(ContactFolder.id == uuid.UUID(folder_id))
.where(ContactFolder.tenant_id == tenant_id)
.where(ContactFolder.user_id == user_id)
.where(ContactFolder.deleted_at.is_(None))
)
folder = result.scalar_one_or_none()
if not folder:
raise ValueError("Folder not found")
if "name" in data and data["name"]:
folder.name = data["name"]
if "parent_id" in data:
new_parent = data["parent_id"]
folder.parent_id = uuid.UUID(new_parent) if new_parent else None
if "sort_order" in data and data["sort_order"] is not None:
folder.sort_order = data["sort_order"]
await db.commit()
await db.refresh(folder)
return _serialize_folder(folder)
async def delete_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
folder_id: str,
) -> None:
"""Delete a contact folder. Contacts are unassigned (folder_id SET NULL)."""
result = await db.execute(
select(ContactFolder)
.where(ContactFolder.id == uuid.UUID(folder_id))
.where(ContactFolder.tenant_id == tenant_id)
.where(ContactFolder.user_id == user_id)
.where(ContactFolder.deleted_at.is_(None))
)
folder = result.scalar_one_or_none()
if not folder:
raise ValueError("Folder not found")
# Unassign contacts in this folder (and subfolders via CASCADE on folder delete)
await db.execute(
update(Contact)
.where(Contact.folder_id == folder.id)
.values(folder_id=None)
)
await db.delete(folder)
await db.commit()
async def move_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
contact_id: str,
folder_id: str | None,
) -> dict:
"""Move a contact to a folder (or unassign with folder_id=None)."""
result = await db.execute(
select(Contact)
.where(Contact.id == uuid.UUID(contact_id))
.where(Contact.tenant_id == tenant_id)
.where(Contact.deleted_at.is_(None))
)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
contact.folder_id = uuid.UUID(folder_id) if folder_id else None
await db.commit()
await db.refresh(contact)
return {"id": str(contact.id), "folder_id": str(contact.folder_id) if contact.folder_id else None}
async def reorder_folders(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
folder_orders: list[dict],
) -> None:
"""Batch update sort_order for folders. Each item: {id, sort_order, parent_id?}."""
for item in folder_orders:
fid = uuid.UUID(item["id"])
await db.execute(
update(ContactFolder)
.where(ContactFolder.id == fid)
.where(ContactFolder.tenant_id == tenant_id)
.where(ContactFolder.user_id == user_id)
.values(
sort_order=item.get("sort_order", 0),
parent_id=uuid.UUID(item["parent_id"]) if item.get("parent_id") else None,
)
)
await db.commit()
+6 -1
View File
@@ -90,6 +90,7 @@ def _serialize_contact(c: Contact) -> dict:
"tags": c.tags, "tags": c.tags,
"image": c.image, "image": c.image,
"custom": c.custom, "custom": c.custom,
"folder_id": str(c.folder_id) if c.folder_id else None,
"default_person_id": str(c.default_person_id) if c.default_person_id else None, "default_person_id": str(c.default_person_id) if c.default_person_id else None,
"admin_contactperson_id": str(c.admin_contactperson_id) if c.admin_contactperson_id else None, "admin_contactperson_id": str(c.admin_contactperson_id) if c.admin_contactperson_id else None,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.created_at.isoformat() if c.created_at else None,
@@ -137,11 +138,12 @@ async def list_contacts(
page_size: int = 20, page_size: int = 20,
search: str | None = None, search: str | None = None,
contact_type: str | None = None, contact_type: str | None = None,
folder_id: str | None = None,
sort_by: str = "displayname", sort_by: str = "displayname",
sort_order: str = "asc", sort_order: str = "asc",
resolved_perms: dict | None = None, resolved_perms: dict | None = None,
) -> dict: ) -> dict:
"""List contacts with pagination, FTS search, type filter, sorting.""" """List contacts with pagination, FTS search, type/folder filter, sorting."""
base = select(Contact).where( base = select(Contact).where(
Contact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
@@ -150,6 +152,9 @@ async def list_contacts(
if contact_type: if contact_type:
base = base.where(Contact.type == contact_type) base = base.where(Contact.type == contact_type)
if folder_id:
base = base.where(Contact.folder_id == uuid.UUID(folder_id))
if search: if search:
base = base.where( base = base.where(
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)) Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
@@ -59,6 +59,11 @@ vi.mock('@/api/hooks', () => ({
useCreateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }), useCreateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
useUpdateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }), useUpdateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
useContactFolders: () => ({ data: [], isLoading: false }),
useCreateContactFolder: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateContactFolder: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteContactFolder: () => ({ mutate: vi.fn(), isPending: false }),
useMoveContactToFolder: () => ({ mutate: vi.fn(), isPending: false }),
})); }));
vi.mock('@/components/ui/Toast', () => ({ vi.mock('@/components/ui/Toast', () => ({
+62
View File
@@ -0,0 +1,62 @@
/**
* Contact folder API client — CRUD, move contacts, reorder.
*/
import { apiGet, apiPost, apiPut, apiDelete } from './client';
export interface ContactFolder {
id: string;
name: string;
parent_id: string | null;
user_id: string;
sort_order: number;
contact_count: number;
}
export interface ContactFolderTreeNode extends ContactFolder {
children: ContactFolderTreeNode[];
}
// ── Folders ──
export const fetchContactFolders = () =>
apiGet<ContactFolder[]>('/contact-folders');
export const createContactFolder = (data: { name: string; parent_id?: string }) =>
apiPost<ContactFolder>('/contact-folders', data);
export const updateContactFolder = (id: string, data: Partial<ContactFolder>) =>
apiPut<ContactFolder>(`/contact-folders/${id}`, data);
export const deleteContactFolder = (id: string) =>
apiDelete(`/contact-folders/${id}`);
export const reorderContactFolders = (folderId: string, orders: { id: string; sort_order: number; parent_id?: string | null }[]) =>
apiPut(`/contact-folders/${folderId}/reorder`, orders);
// ── Move contact ──
export const moveContactToFolder = (contactId: string, folderId: string | null) =>
apiPut<{ id: string; folder_id: string | null }>(`/contact-folders/contacts/${contactId}/move`, { folder_id: folderId });
// ── Tree builder ──
export function buildFolderTree(folders: ContactFolder[]): ContactFolderTreeNode[] {
const folderMap = new Map<string, ContactFolderTreeNode>();
const roots: ContactFolderTreeNode[] = [];
for (const f of folders) {
folderMap.set(f.id, { ...f, children: [] });
}
for (const f of folders) {
const node = folderMap.get(f.id)!;
if (f.parent_id && folderMap.has(f.parent_id)) {
folderMap.get(f.parent_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
+56 -1
View File
@@ -669,6 +669,7 @@ export interface UnifiedContact {
tags?: string | null; tags?: string | null;
image?: string | null; image?: string | null;
custom?: Record<string, any> | null; custom?: Record<string, any> | null;
folder_id?: string | null;
default_person_id?: string | null; default_person_id?: string | null;
admin_contactperson_id?: string | null; admin_contactperson_id?: string | null;
contact_persons?: ContactPerson[]; contact_persons?: ContactPerson[];
@@ -683,14 +684,16 @@ export function useUnifiedContacts(
contactType?: string, contactType?: string,
sortBy?: string, sortBy?: string,
sortOrder?: string, sortOrder?: string,
folderId?: string,
) { ) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search); if (search) params.set('search', search);
if (contactType) params.set('type', contactType); if (contactType) params.set('type', contactType);
if (sortBy) params.set('sort_by', sortBy); if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder); if (sortOrder) params.set('sort_order', sortOrder);
if (folderId) params.set('folder_id', folderId);
return useQuery({ return useQuery({
queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder], queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder, folderId],
queryFn: () => queryFn: () =>
apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`), apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`),
}); });
@@ -1240,3 +1243,55 @@ export function useUserGroups(userId: string | null) {
enabled: !!userId, enabled: !!userId,
}); });
} }
// ── Contact Folders ──
export function useContactFolders() {
return useQuery({
queryKey: ['contactFolders'],
queryFn: () => apiGet<import('./contactFolders').ContactFolder[]>('/contact-folders'),
});
}
export function useCreateContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parent_id?: string }) => apiPost('/contact-folders', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useUpdateContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: { name?: string; parent_id?: string | null; sort_order?: number } }) =>
apiPut(`/contact-folders/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useDeleteContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/contact-folders/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useMoveContactToFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, folderId }: { contactId: string; folderId: string | null }) =>
apiPut(`/contact-folders/contacts/${contactId}/move`, { folder_id: folderId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
@@ -1,16 +1,27 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
useContactFolders,
useCreateContactFolder,
useUpdateContactFolder,
useDeleteContactFolder,
useMoveContactToFolder,
} from '@/api/hooks';
import { buildFolderTree, type ContactFolderTreeNode } from '@/api/contactFolders';
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}`; export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`;
export interface ContactFolderTreeProps { export interface ContactFolderTreeProps {
selectedFilter: ContactFilter; selectedFilter: ContactFilter;
onSelect: (filter: ContactFilter) => void; onSelect: (filter: ContactFilter) => void;
tags: string[]; tags: string[];
loading?: boolean; loading?: boolean;
contacts?: { id: string; displayname: string; type: string; folder_id?: string | null }[];
} }
// ── Icons ──
const icon = (path: string, cls = 'w-4 h-4') => ( const icon = (path: string, cls = 'w-4 h-4') => (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
@@ -31,16 +42,221 @@ const ICONS = {
company: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', company: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
person: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z', person: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
tag: 'M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z', tag: 'M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z',
// Contact person icon (used instead of folder icon)
folder: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
contactPerson: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
edit: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z',
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
plus: 'M12 4v16m8-8H4',
}; };
// ── Context Menu ──
interface ContextMenuState {
x: number;
y: number;
type: 'folder' | 'root';
id: string | null;
}
function ContextMenu({
state, onClose, onRename, onDelete, onNewFolder, onNewSubfolder,
}: {
state: ContextMenuState;
onClose: () => void;
onRename: (id: string) => void;
onDelete: (id: string) => void;
onNewFolder: () => void;
onNewSubfolder: (parentId: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
const items: { label: string; action: () => void; danger?: boolean }[] = [];
if (state.type === 'folder') {
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!); onClose(); } });
items.push({ label: 'Neuer Unterordner', action: () => { onNewSubfolder(state.id!); onClose(); } });
items.push({ label: 'Löschen', action: () => { onDelete(state.id!); onClose(); }, danger: true });
} else {
items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } });
}
return (
<div
ref={ref}
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ left: state.x, top: state.y }}
>
{items.map((item, i) => (
<button
key={i}
onClick={item.action}
className={clsx(
'w-full text-left px-3 py-1.5 text-sm hover:bg-secondary-100',
item.danger && 'text-red-600 hover:bg-red-50'
)}
>
{item.label}
</button>
))}
</div>
);
}
// ── Folder Tree Item ──
function FolderTreeItem({
node,
depth,
selectedFilter,
onSelectFolder,
onContextMenu,
isDragOver,
onDragOver,
onDragLeave,
onDrop,
contacts,
}: {
node: ContactFolderTreeNode;
depth: number;
selectedFilter: ContactFilter;
onSelectFolder: (folderId: string) => void;
onContextMenu: (e: React.MouseEvent, type: 'folder' | 'root', id: string | null) => void;
isDragOver: boolean;
onDragOver: (e: React.DragEvent, folderId: string) => void;
onDragLeave: (folderId: string) => void;
onDrop: (e: React.DragEvent, folderId: string) => void;
contacts: { id: string; displayname: string; type: string; folder_id?: string | null }[];
}) {
const [expanded, setExpanded] = useState(true);
const folderKey = `folder:${node.id}` as ContactFilter;
const isActive = selectedFilter === folderKey;
const folderContacts = contacts.filter((c) => c.folder_id === node.id);
return (
<div>
{/* Folder header — drop target */}
<div
onDragOver={(e) => onDragOver(e, node.id)}
onDragLeave={() => onDragLeave(node.id)}
onDrop={(e) => onDrop(e, node.id)}
onContextMenu={(e) => onContextMenu(e, 'folder', node.id)}
className={clsx(
'group flex items-center gap-1.5 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer min-h-touch',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isDragOver
? 'bg-primary-100 ring-2 ring-primary-400'
: isActive
? 'bg-primary-50 text-primary-700'
: 'text-secondary-700 hover:bg-secondary-100',
)}
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => {
setExpanded(!expanded);
onSelectFolder(node.id);
}}
>
{chevron(expanded)}
{/* Contact person icon instead of folder icon */}
{icon(ICONS.contactPerson, 'w-4 h-4 text-secondary-400')}
<span className="flex-1 truncate">{node.name}</span>
{node.contact_count > 0 && (
<span className="text-xs text-secondary-400 tabular-nums">{node.contact_count}</span>
)}
<button
onClick={(e) => { e.stopPropagation(); onContextMenu(e, 'folder', node.id); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5"
title="Umbenennen"
>
{icon(ICONS.edit, 'w-3.5 h-3.5')}
</button>
<button
onClick={(e) => { e.stopPropagation(); onContextMenu(e, 'folder', node.id); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 p-0.5"
title="Löschen"
>
{icon(ICONS.trash, 'w-3.5 h-3.5')}
</button>
</div>
{/* Contacts inside folder (leaf nodes) */}
{expanded && folderContacts.length > 0 && (
<div>
{folderContacts.map((contact) => (
<div
key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectFolder(node.id)}
className={clsx(
'group flex items-center gap-2 px-3 py-1 text-sm cursor-grab rounded-md hover:bg-secondary-100 min-h-touch',
)}
style={{ paddingLeft: depth * 12 + 32 }}
>
{icon(
contact.type === 'company' ? ICONS.company : ICONS.person,
'w-3.5 h-3.5 text-secondary-400 flex-shrink-0',
)}
<span className="flex-1 truncate text-secondary-600">{contact.displayname}</span>
</div>
))}
</div>
)}
{/* Child folders */}
{expanded && node.children.map((child) => (
<FolderTreeItem
key={child.id}
node={child}
depth={depth + 1}
selectedFilter={selectedFilter}
onSelectFolder={onSelectFolder}
onContextMenu={onContextMenu}
isDragOver={false}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
contacts={contacts}
/>
))}
</div>
);
}
// ── Main Component ──
export function ContactFolderTree({ export function ContactFolderTree({
selectedFilter, selectedFilter,
onSelect, onSelect,
tags, tags,
loading, loading,
contacts = [],
}: ContactFolderTreeProps) { }: ContactFolderTreeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tagsOpen, setTagsOpen] = useState(true); const [tagsOpen, setTagsOpen] = useState(true);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [dragContactId, setDragContactId] = useState<string | null>(null);
const { data: folders, isLoading: foldersLoading } = useContactFolders();
const createFolderMut = useCreateContactFolder();
const updateFolderMut = useUpdateContactFolder();
const deleteFolderMut = useDeleteContactFolder();
const moveContactMut = useMoveContactToFolder();
const folderList = folders ?? [];
const tree = buildFolderTree(folderList);
const itemCls = (active: boolean) => const itemCls = (active: boolean) =>
clsx( clsx(
@@ -49,6 +265,67 @@ export function ContactFolderTree({
active ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100', active ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100',
); );
const handleContextMenu = useCallback((e: React.MouseEvent, type: 'folder' | 'root', id: string | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, type, id });
}, []);
const handleNewFolder = () => {
const name = prompt('Ordnername:');
if (!name) return;
createFolderMut.mutate({ name });
};
const handleNewSubfolder = (parentId: string) => {
const name = prompt('Unterordnername:');
if (!name) return;
createFolderMut.mutate({ name, parent_id: parentId });
};
const handleRename = (id: string) => {
const folder = folderList.find((f) => f.id === id);
const newName = prompt('Neuer Name:', folder?.name || '');
if (!newName) return;
updateFolderMut.mutate({ id, data: { name: newName } });
};
const handleDelete = (id: string) => {
if (!confirm('Ordner löschen? Kontakte bleiben erhalten, werden aber keinem Ordner mehr zugeordnet.')) return;
deleteFolderMut.mutate(id);
};
const handleDragOver = (e: React.DragEvent, folderId: string) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolderId(folderId);
};
const handleDragLeave = (folderId: string) => {
if (dragOverFolderId === folderId) setDragOverFolderId(null);
};
const handleDrop = (e: React.DragEvent, folderId: string) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolderId(null);
const contactId = e.dataTransfer.getData('text/plain');
if (!contactId) return;
moveContactMut.mutate({ contactId, folderId });
};
// Root drop zone (unassign from folder)
const handleRootDrop = (e: React.DragEvent) => {
e.preventDefault();
const contactId = e.dataTransfer.getData('text/plain');
if (!contactId) return;
moveContactMut.mutate({ contactId, folderId: null });
};
const handleRootDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
return ( return (
<nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree"> <nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
{/* Alle Kontakte */} {/* Alle Kontakte */}
@@ -81,6 +358,48 @@ export function ContactFolderTree({
<span>{t('contacts.persons')}</span> <span>{t('contacts.persons')}</span>
</button> </button>
{/* Folders section */}
<div className="pt-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">Ordner</span>
<button
onClick={handleNewFolder}
className="text-secondary-400 hover:text-primary-600 p-0.5"
title="Neuer Ordner"
>
{icon(ICONS.plus, 'w-3.5 h-3.5')}
</button>
</div>
{tree.map((node) => (
<FolderTreeItem
key={node.id}
node={node}
depth={0}
selectedFilter={selectedFilter}
onSelectFolder={(folderId) => onSelect(`folder:${folderId}` as ContactFilter)}
onContextMenu={handleContextMenu}
isDragOver={dragOverFolderId === node.id}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
contacts={contacts}
/>
))}
{/* Root drop zone — unassign contact */}
<div
onDragOver={handleRootDragOver}
onDrop={handleRootDrop}
className="min-h-[12px] mt-1 rounded-md transition-colors hover:bg-secondary-50"
onContextMenu={(e) => handleContextMenu(e, 'root', null)}
/>
{(foldersLoading || loading) && (
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
)}
</div>
{/* Tags — collapsible */} {/* Tags — collapsible */}
{tags.length > 0 && ( {tags.length > 0 && (
<div className="pt-1"> <div className="pt-1">
@@ -115,8 +434,15 @@ export function ContactFolderTree({
</div> </div>
)} )}
{loading && ( {contextMenu && (
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div> <ContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onRename={handleRename}
onDelete={handleDelete}
onNewFolder={handleNewFolder}
onNewSubfolder={handleNewSubfolder}
/>
)} )}
</nav> </nav>
); );
@@ -113,6 +113,11 @@ export function ContactList({
{contacts.map((contact) => ( {contacts.map((contact) => (
<li key={contact.id}> <li key={contact.id}>
<button <button
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)} onClick={() => onSelectContact(contact)}
className={clsx( className={clsx(
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors', 'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
@@ -201,6 +206,11 @@ export function ContactList({
{contacts.map((contact) => ( {contacts.map((contact) => (
<tr <tr
key={contact.id} key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)} onClick={() => onSelectContact(contact)}
className={clsx( className={clsx(
'cursor-pointer transition-colors', 'cursor-pointer transition-colors',
@@ -247,6 +257,11 @@ export function ContactList({
{contacts.map((contact) => ( {contacts.map((contact) => (
<div <div
key={contact.id} key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)} onClick={() => onSelectContact(contact)}
className={clsx( className={clsx(
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch', 'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
+9
View File
@@ -53,6 +53,12 @@ export function ContactsListPage() {
return undefined; return undefined;
}, [selectedFilter]); }, [selectedFilter]);
// Derive folder filter
const folderId = useMemo(() => {
if (selectedFilter.startsWith('folder:')) return selectedFilter.slice(7);
return undefined;
}, [selectedFilter]);
// Derive tag filter // Derive tag filter
const tagFilter = useMemo(() => { const tagFilter = useMemo(() => {
if (selectedFilter.startsWith('tag:')) return selectedFilter.slice(4); if (selectedFilter.startsWith('tag:')) return selectedFilter.slice(4);
@@ -67,6 +73,7 @@ export function ContactsListPage() {
contactType, contactType,
sortBy, sortBy,
sortOrder, sortOrder,
folderId,
); );
// Fetch selected contact detail (with contact_persons) // Fetch selected contact detail (with contact_persons)
@@ -219,6 +226,7 @@ export function ContactsListPage() {
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
onSelect={handleSelectFilter} onSelect={handleSelectFilter}
tags={allTags} tags={allTags}
contacts={contacts}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -351,6 +359,7 @@ export function ContactsListPage() {
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
onSelect={handleSelectFilter} onSelect={handleSelectFilter}
tags={allTags} tags={allTags}
contacts={contacts}
/> />
</div> </div>
</div> </div>