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
+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;
image?: string | null;
custom?: Record<string, any> | 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<PaginatedResponse<UnifiedContact>>(`/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<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'] });
},
});
}