diff --git a/alembic/versions/0022_contact_folders.py b/alembic/versions/0022_contact_folders.py new file mode 100644 index 0000000..a2bec5f --- /dev/null +++ b/alembic/versions/0022_contact_folders.py @@ -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") diff --git a/app/main.py b/app/main.py index 06907ff..d005364 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ from app.routes import ( audit, auth, companies, + contact_folders, contacts, groups, health, @@ -230,6 +231,7 @@ def create_app() -> FastAPI: app.include_router(notifications.router) app.include_router(companies.router) app.include_router(contacts.router) + app.include_router(contact_folders.router) app.include_router(import_export.router) app.include_router(plugins.router) app.include_router(ai_copilot.router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 19635a6..2e33f62 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.audit import AuditLog, DeletionLog from app.models.auth import ApiToken, PasswordResetToken from app.models.company import Company from app.models.contact import Contact, ContactPerson +from app.models.contact_folder import ContactFolder from app.models.currency import Currency from app.models.group import Group, UserGroup from app.models.notification import Notification, NotificationPreference, NotificationType @@ -38,6 +39,7 @@ __all__ = [ "Company", "Contact", "ContactPerson", + "ContactFolder", "Currency", "TaxRate", "Sequence", diff --git a/app/models/contact.py b/app/models/contact.py index 8895438..8118f5f 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -142,6 +142,14 @@ class Contact(Base, TenantMixin): 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: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict) diff --git a/app/models/contact_folder.py b/app/models/contact_folder.py new file mode 100644 index 0000000..afe0614 --- /dev/null +++ b/app/models/contact_folder.py @@ -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", + ) diff --git a/app/routes/contact_folders.py b/app/routes/contact_folders.py new file mode 100644 index 0000000..b405031 --- /dev/null +++ b/app/routes/contact_folders.py @@ -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)) diff --git a/app/routes/contacts.py b/app/routes/contacts.py index 706bf69..5909bcf 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -29,17 +29,19 @@ async def list_contacts( page_size: int = Query(20, ge=1, le=100), search: str | None = Query(None), type: str | None = Query(None, pattern="^(company|person)$"), + folder_id: str | None = Query(None), sort_by: str = Query("displayname"), sort_order: str = Query("asc", pattern="^(asc|desc)$"), db: AsyncSession = Depends(get_db), 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"]) return await contact_service.list_contacts( db, tenant_id, 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, ) diff --git a/app/schemas/contact.py b/app/schemas/contact.py index 85b22e9..0bcc723 100644 --- a/app/schemas/contact.py +++ b/app/schemas/contact.py @@ -141,6 +141,8 @@ class ContactCreate(BaseModel): image: str | None = None # Custom custom: dict | None = None + # Folder assignment + folder_id: str | None = None # Contact persons (optional inline create) contact_persons: list[ContactPersonCreate] | None = None @@ -208,6 +210,7 @@ class ContactUpdate(BaseModel): tags: str | None = Field(None, max_length=500) image: str | None = None custom: dict | None = None + folder_id: str | None = None default_person_id: str | None = None admin_contactperson_id: str | None = None @@ -278,6 +281,7 @@ class ContactResponse(BaseModel): tags: str | None = None image: str | None = None custom: dict | None = None + folder_id: str | None = None default_person_id: str | None = None admin_contactperson_id: str | None = None created_at: str | None = None diff --git a/app/schemas/contact_folder.py b/app/schemas/contact_folder.py new file mode 100644 index 0000000..4668cf8 --- /dev/null +++ b/app/schemas/contact_folder.py @@ -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 diff --git a/app/services/contact_folder_service.py b/app/services/contact_folder_service.py new file mode 100644 index 0000000..c05656c --- /dev/null +++ b/app/services/contact_folder_service.py @@ -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() diff --git a/app/services/contact_service.py b/app/services/contact_service.py index 5811dd3..69b53a3 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -90,6 +90,7 @@ def _serialize_contact(c: Contact) -> dict: "tags": c.tags, "image": c.image, "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, "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, @@ -137,11 +138,12 @@ async def list_contacts( page_size: int = 20, search: str | None = None, contact_type: str | None = None, + folder_id: str | None = None, sort_by: str = "displayname", sort_order: str = "asc", resolved_perms: dict | None = None, ) -> dict: - """List contacts with pagination, FTS search, type filter, sorting.""" + """List contacts with pagination, FTS search, type/folder filter, sorting.""" base = select(Contact).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), @@ -150,6 +152,9 @@ async def list_contacts( if contact_type: base = base.where(Contact.type == contact_type) + if folder_id: + base = base.where(Contact.folder_id == uuid.UUID(folder_id)) + if search: base = base.where( Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)) diff --git a/frontend/src/__tests__/contacts/ContactsList.test.tsx b/frontend/src/__tests__/contacts/ContactsList.test.tsx index 132081c..bdc9b17 100644 --- a/frontend/src/__tests__/contacts/ContactsList.test.tsx +++ b/frontend/src/__tests__/contacts/ContactsList.test.tsx @@ -59,6 +59,11 @@ vi.mock('@/api/hooks', () => ({ useCreateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }), useUpdateContactPerson: () => ({ 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', () => ({ diff --git a/frontend/src/api/contactFolders.ts b/frontend/src/api/contactFolders.ts new file mode 100644 index 0000000..12db888 --- /dev/null +++ b/frontend/src/api/contactFolders.ts @@ -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('/contact-folders'); + +export const createContactFolder = (data: { name: string; parent_id?: string }) => + apiPost('/contact-folders', data); + +export const updateContactFolder = (id: string, data: Partial) => + apiPut(`/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(); + 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; +} diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 1d0de37..cf1f5fb 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -669,6 +669,7 @@ export interface UnifiedContact { tags?: string | null; image?: string | null; custom?: Record | null; + folder_id?: string | null; default_person_id?: string | null; admin_contactperson_id?: string | null; contact_persons?: ContactPerson[]; @@ -683,14 +684,16 @@ export function useUnifiedContacts( contactType?: string, sortBy?: string, sortOrder?: string, + folderId?: string, ) { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (search) params.set('search', search); if (contactType) params.set('type', contactType); if (sortBy) params.set('sort_by', sortBy); if (sortOrder) params.set('sort_order', sortOrder); + if (folderId) params.set('folder_id', folderId); return useQuery({ - queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder], + queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder, folderId], queryFn: () => apiGet>(`/contacts?${params.toString()}`), }); @@ -1240,3 +1243,55 @@ export function useUserGroups(userId: string | null) { enabled: !!userId, }); } + +// ── Contact Folders ── + +export function useContactFolders() { + return useQuery({ + queryKey: ['contactFolders'], + queryFn: () => apiGet('/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'] }); + }, + }); +} diff --git a/frontend/src/components/contacts/ContactFolderTree.tsx b/frontend/src/components/contacts/ContactFolderTree.tsx index f333ba2..2a5c068 100644 --- a/frontend/src/components/contacts/ContactFolderTree.tsx +++ b/frontend/src/components/contacts/ContactFolderTree.tsx @@ -1,16 +1,27 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import clsx from 'clsx'; 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 { selectedFilter: ContactFilter; onSelect: (filter: ContactFilter) => void; tags: string[]; loading?: boolean; + contacts?: { id: string; displayname: string; type: string; folder_id?: string | null }[]; } +// ── Icons ── + const icon = (path: string, cls = 'w-4 h-4') => (
+ {items.map((item, i) => ( + + ))} +
+ ); +} + +// ── 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 ( +
+ {/* Folder header — drop target */} +
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')} + {node.name} + {node.contact_count > 0 && ( + {node.contact_count} + )} + + +
+ + {/* Contacts inside folder (leaf nodes) */} + {expanded && folderContacts.length > 0 && ( +
+ {folderContacts.map((contact) => ( +
{ + 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', + )} + {contact.displayname} +
+ ))} +
+ )} + + {/* Child folders */} + {expanded && node.children.map((child) => ( + + ))} +
+ ); +} + +// ── Main Component ── + export function ContactFolderTree({ selectedFilter, onSelect, tags, loading, + contacts = [], }: ContactFolderTreeProps) { const { t } = useTranslation(); const [tagsOpen, setTagsOpen] = useState(true); + const [contextMenu, setContextMenu] = useState(null); + const [dragOverFolderId, setDragOverFolderId] = useState(null); + const [dragContactId, setDragContactId] = useState(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) => clsx( @@ -49,6 +265,67 @@ export function ContactFolderTree({ 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 ( ); diff --git a/frontend/src/components/contacts/ContactList.tsx b/frontend/src/components/contacts/ContactList.tsx index 3845bf5..e8102bf 100644 --- a/frontend/src/components/contacts/ContactList.tsx +++ b/frontend/src/components/contacts/ContactList.tsx @@ -113,6 +113,11 @@ export function ContactList({ {contacts.map((contact) => (