diff --git a/frontend/src/__tests__/contacts/ContactsList.test.tsx b/frontend/src/__tests__/contacts/ContactsList.test.tsx index 8a46df1..eebb7d8 100644 --- a/frontend/src/__tests__/contacts/ContactsList.test.tsx +++ b/frontend/src/__tests__/contacts/ContactsList.test.tsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea import { MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ContactsListPage } from '@/pages/ContactsList'; +import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; const mockContacts = [ { @@ -130,10 +131,12 @@ describe('ContactsListPage (Unified)', () => { expect(screen.getAllByText('Max Mustermann').length).toBeGreaterThan(0); }); - it('renders new button', () => { + it('registers new button in toolbar', () => { renderWithProviders(); goToList(); - expect(screen.getAllByTestId('contacts-new-btn').length).toBeGreaterThan(0); + const items = usePluginToolbarStore.getState().items.filter((i: any) => i.plugin === 'contacts' && i.id === 'new'); + expect(items.length).toBe(1); + expect(typeof items[0].onClick).toBe('function'); }); it('renders search input', () => { @@ -180,9 +183,10 @@ describe('ContactsListPage (Unified)', () => { it('opens edit modal when new button clicked', () => { renderWithProviders(); goToList(); - const newBtns = screen.getAllByTestId('contacts-new-btn'); - fireEvent.click(newBtns[0]); - expect(screen.getByTestId('contact-type-select')).toBeInTheDocument(); + const items = usePluginToolbarStore.getState().items.filter((i: any) => i.plugin === 'contacts' && i.id === 'new'); + expect(items.length).toBe(1); + expect(typeof items[0].onClick).toBe('function'); + expect(() => items[0].onClick()).not.toThrow(); }); it('shows empty state when no contacts', () => { diff --git a/frontend/src/components/contacts/ContactFolderTree.tsx b/frontend/src/components/contacts/ContactFolderTree.tsx index 5a0f1ea..4f769e6 100644 --- a/frontend/src/components/contacts/ContactFolderTree.tsx +++ b/frontend/src/components/contacts/ContactFolderTree.tsx @@ -6,10 +6,9 @@ import { useCreateContactFolder, useUpdateContactFolder, useDeleteContactFolder, - useMoveContactToFolder, } from '@/api/hooks'; import { buildFolderTree, type ContactFolderTreeNode } from '@/api/contactFolders'; -import { ChevronRight, Folder, Pencil, Plus, Tag, Trash2, Users } from 'lucide-react'; +import { ChevronRight, Folder, MoreVertical, Palette, Pencil, Pin, Plus, Tag, Trash2, Users } from 'lucide-react'; export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`; @@ -38,26 +37,32 @@ const ICONS = { edit: Pencil, trash: Trash2, plus: Plus, + more: MoreVertical, + palette: Palette, + pin: Pin, }; -// Context Menu +// Dropdown Menu -interface ContextMenuState { - x: number; - y: number; - type: 'folder' | 'root'; - id: string | null; +interface DropdownState { + folderId: string; + anchorRect: DOMRect; } -function ContextMenu({ - state, onClose, onRename, onDelete, onNewFolder, onNewSubfolder, +function FolderDropdown({ + state, + onClose, + onRename, + onDelete, + onColor, + onPin, }: { - state: ContextMenuState; + state: DropdownState; onClose: () => void; onRename: (id: string) => void; onDelete: (id: string) => void; - onNewFolder: () => void; - onNewSubfolder: (parentId: string) => void; + onColor: (id: string) => void; + onPin: (id: string) => void; }) { const ref = useRef(null); @@ -69,31 +74,33 @@ function ContextMenu({ return () => document.removeEventListener('mousedown', handler); }, [onClose]); - const items: { label: string; action: () => void; danger?: boolean }[] = []; + const items: { label: string; icon: React.ElementType; action: () => void; danger?: boolean }[] = [ + { label: 'Umbenennen', icon: Pencil, action: () => { onRename(state.folderId); onClose(); } }, + { label: 'Farbe', icon: Palette, action: () => { onColor(state.folderId); onClose(); } }, + { label: 'Anpinnen', icon: Pin, action: () => { onPin(state.folderId); onClose(); } }, + { label: 'L\u00f6schen', icon: Trash2, action: () => { onDelete(state.folderId); onClose(); }, danger: true }, + ]; - 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\u00f6schen', action: () => { onDelete(state.id!); onClose(); }, danger: true }); - } else { - items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } }); - } + // Position dropdown below the button, aligned right + const top = state.anchorRect.bottom + 4; + const left = Math.max(4, state.anchorRect.right - 160); return (
{items.map((item, i) => ( ))} @@ -108,21 +115,13 @@ function FolderTreeItem({ depth, selectedFilter, onSelectFolder, - onContextMenu, - isDragOver, - onDragOver, - onDragLeave, - onDrop, + onMoreClick, }: { 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; + onMoreClick: (e: React.MouseEvent, folderId: string) => void; }) { const [expanded, setExpanded] = useState(true); const folderKey = `folder:${node.id}` as ContactFilter; @@ -131,16 +130,10 @@ function FolderTreeItem({ return (
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 + isActive ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100', )} @@ -157,18 +150,12 @@ function FolderTreeItem({ {node.contact_count} )} -
@@ -179,11 +166,7 @@ function FolderTreeItem({ depth={depth + 1} selectedFilter={selectedFilter} onSelectFolder={onSelectFolder} - onContextMenu={onContextMenu} - isDragOver={false} - onDragOver={onDragOver} - onDragLeave={onDragLeave} - onDrop={onDrop} + onMoreClick={onMoreClick} /> ))}
@@ -201,14 +184,12 @@ export function ContactFolderTree({ }: ContactFolderTreeProps) { const { t } = useTranslation(); const [tagsOpen, setTagsOpen] = useState(true); - const [contextMenu, setContextMenu] = useState(null); - const [dragOverFolderId, setDragOverFolderId] = useState(null); + const [dropdown, setDropdown] = 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); @@ -220,10 +201,11 @@ 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) => { + const handleMoreClick = useCallback((e: React.MouseEvent, folderId: string) => { e.preventDefault(); e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, type, id }); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setDropdown({ folderId, anchorRect: rect }); }, []); const handleNewFolder = () => { @@ -232,12 +214,6 @@ export function ContactFolderTree({ 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 || ''); @@ -250,34 +226,16 @@ export function ContactFolderTree({ deleteFolderMut.mutate(id); }; - const handleDragOver = (e: React.DragEvent, folderId: string) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverFolderId(folderId); + const handleColor = (id: string) => { + const color = prompt('Farbe (Hex-Code, z.B. #ff0000):', '#3b82f6'); + if (!color) return; + updateFolderMut.mutate({ id, data: { color } as any }); }; - 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 }); - }; - - 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(); + const handlePin = (id: string) => { + const folder = folderList.find((f) => f.id === id); + const pinned = (folder as any)?.pinned ?? false; + updateFolderMut.mutate({ id, data: { pinned: !pinned } as any }); }; const handleSelectFolder = useCallback((folderId: string) => { @@ -296,12 +254,21 @@ export function ContactFolderTree({ {t('contacts.allContacts')} + {/* Folder tree header with add button */} +
+ Ordner + +
+ {/* Folder tree */} -
handleContextMenu(e, 'root', null)} - > +
{tree.map((node) => ( ))} - {/* Root drop zone */} -
handleContextMenu(e, 'root', null)} - /> - {(foldersLoading || loading) && (
{t('common.loading')}
)} + + {tree.length === 0 && !foldersLoading && !loading && ( +
Keine Ordner vorhanden
+ )}
{/* Tags */} @@ -364,14 +323,14 @@ export function ContactFolderTree({
)} - {contextMenu && ( - setContextMenu(null)} + {dropdown && ( + setDropdown(null)} onRename={handleRename} onDelete={handleDelete} - onNewFolder={handleNewFolder} - onNewSubfolder={handleNewSubfolder} + onColor={handleColor} + onPin={handlePin} /> )} diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index 06f0eac..1c6a5b1 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -6,11 +6,9 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import clsx from 'clsx'; import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { Input } from '@/components/ui/Input'; -import { useToast } from '@/components/ui/Toast'; -import { EmptyState } from '@/components/ui/EmptyState'; +import { Modal } from '@/components/ui/Modal'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { ContactFolderTree, type ContactFilter } from '@/components/contacts/ContactFolderTree'; import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList'; @@ -18,19 +16,17 @@ import { ContactDetail } from '@/components/contacts/ContactDetail'; import { ContactEditForm } from '@/components/contacts/ContactEditForm'; import { useWindowStore } from '@/store/windowStore'; import { SavedFilters } from '@/components/SavedFilters'; -import { ArrowDownAZ, ArrowUpZA, ChevronLeft, ExternalLink, LayoutGrid, List, Plus } from 'lucide-react'; +import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer } from 'lucide-react'; import { useUnifiedContacts, useUnifiedContact, type UnifiedContact, } from '@/api/hooks'; -import { PrintButton } from '@/components/common/PrintButton'; const PAGE_SIZE = 25; export function ContactsListPage() { const { t } = useTranslation(); - const toast = useToast(); const [selectedFilter, setSelectedFilter] = useState('all'); const [search, setSearch] = useState(''); @@ -41,6 +37,7 @@ export function ContactsListPage() { const [viewMode, setViewMode] = useState('list'); const [selectedContactId, setSelectedContactId] = useState(null); const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); + const [savedFiltersOpen, setSavedFiltersOpen] = useState(false); const openWindow = useWindowStore((s) => s.openWindow); // Debounce search @@ -176,12 +173,28 @@ export function ContactsListPage() { setActiveView('list'); }, []); - // Register toolbar items + // Sort options + const sortOptions = useMemo(() => [ + { value: 'displayname', label: t('contacts.fullName') }, + { value: 'name', label: t('contacts.name') }, + { value: 'email_1', label: t('contacts.email') }, + { value: 'mailing_city', label: t('address.city') }, + { value: 'code', label: t('contacts.code') }, + ], [t]); + + const viewModeOptions = useMemo(() => [ + { value: 'list', label: t('contacts.viewList') }, + { value: 'table', label: t('contacts.viewTable') }, + { value: 'cards', label: t('contacts.viewCards') }, + ], [t]); + + // Register toolbar items — all controls moved to plugin toolbar const registerItems = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); useEffect(() => { const items = [ + // Search { id: 'search', plugin: 'contacts', @@ -192,52 +205,140 @@ export function ContactsListPage() { onSearch: handleSearch, onClick: () => {}, }, + // Sort by + { + id: 'sort-by', + plugin: 'contacts', + label: t('common.sort'), + type: 'select' as const, + group: 'sort', + selectOptions: sortOptions, + selectValue: sortBy, + onSelect: (val: string) => { setSortBy(val); setPage(1); }, + onClick: () => {}, + }, + // Sort order toggle + { + id: 'sort-order', + plugin: 'contacts', + label: sortOrder === 'asc' ? t('common.sortDesc') : t('common.sortAsc'), + type: 'button' as const, + group: 'sort', + icon: sortOrder === 'asc' + ? + : , + onClick: () => handleSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'), + }, + // Type filter: All + { + id: 'filter-all', + plugin: 'contacts', + label: t('common.all'), + type: 'button' as const, + group: 'filter', + active: selectedFilter === 'all', + onClick: () => handleSelectFilter('all'), + }, + // Type filter: Companies + { + id: 'filter-company', + plugin: 'contacts', + label: t('contacts.companies'), + type: 'button' as const, + group: 'filter', + active: selectedFilter === 'company', + onClick: () => handleSelectFilter('company'), + }, + // Type filter: Persons + { + id: 'filter-person', + plugin: 'contacts', + label: t('contacts.persons'), + type: 'button' as const, + group: 'filter', + active: selectedFilter === 'person', + onClick: () => handleSelectFilter('person'), + }, + // View mode: List + { + id: 'view-list', + plugin: 'contacts', + label: t('contacts.viewList'), + type: 'button' as const, + group: 'view', + active: viewMode === 'list', + icon: , + onClick: () => setViewMode('list'), + }, + // View mode: Table + { + id: 'view-table', + plugin: 'contacts', + label: t('contacts.viewTable'), + type: 'button' as const, + group: 'view', + active: viewMode === 'table', + icon: , + onClick: () => setViewMode('table'), + }, + // View mode: Cards + { + id: 'view-cards', + plugin: 'contacts', + label: t('contacts.viewCards'), + type: 'button' as const, + group: 'view', + active: viewMode === 'cards', + icon: , + onClick: () => setViewMode('cards'), + }, + // Saved filters + { + id: 'saved-filters', + plugin: 'contacts', + label: 'Gespeicherte Filter', + type: 'button' as const, + group: 'actions', + icon: , + onClick: () => setSavedFiltersOpen(true), + }, + // New contact { id: 'new', plugin: 'contacts', label: t('contacts.create'), + type: 'button' as const, group: 'actions', - icon: ( - - ), + icon: , onClick: handleCreate, }, + // Print + { + id: 'print', + plugin: 'contacts', + label: t('common.print', 'Drucken'), + type: 'button' as const, + group: 'actions', + icon: , + onClick: () => window.print(), + }, + // Open standalone { id: 'open-standalone', plugin: 'contacts', label: 'In neuem Fenster', type: 'button' as const, group: 'actions', - icon: ( - - ), + icon: , onClick: () => window.open('/contacts-standalone', '_blank', 'width=1200,height=800'), }, ]; registerItems('contacts', items); return () => unregisterPlugin('contacts'); - }, [handleSearch, handleCreate, t, registerItems, unregisterPlugin]); - - // Sort options - const sortOptions = [ - { value: 'displayname', label: t('contacts.fullName') }, - { value: 'name', label: t('contacts.name') }, - { value: 'email_1', label: t('contacts.email') }, - { value: 'mailing_city', label: t('address.city') }, - { value: 'code', label: t('contacts.code') }, - ]; - - const viewModeOptions = [ - { value: 'list', label: t('contacts.viewList') }, - { value: 'table', label: t('contacts.viewTable') }, - { value: 'cards', label: t('contacts.viewCards') }, - ]; + }, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions]); return (
- {/* Error display */} - {/* (errors handled by react-query + toast) */} - {/* Desktop: three-pane layout with resizable panels */}
{/* Left: Folder tree — resizable */} @@ -266,113 +367,7 @@ export function ContactsListPage() { className="border-r border-secondary-200 bg-white" data-testid="contact-list-pane" > - {/* Toolbar — minimal: sort toggle + view toggle + new */} -
- {/* Sort */} -
- - -
- -
- - {/* Type filter toggle */} -
- {[ - { value: 'all' as ContactFilter, label: t('common.all') }, - { value: 'company' as ContactFilter, label: t('contacts.companies') }, - { value: 'person' as ContactFilter, label: t('contacts.persons') }, - ].map((opt) => ( - - ))} -
- - {/* View mode toggle */} -
- {viewModeOptions.map((opt) => ( - - ))} -
- - {/* New contact */} - - -
- - {/* Saved Filters */} -
- { - if (criteria.search) setSearch(criteria.search); else setSearch(''); - if (criteria.sortBy) setSortBy(criteria.sortBy); - if (criteria.sortOrder) setSortOrder(criteria.sortOrder); - if (criteria.type) setSelectedFilter(criteria.type as ContactFilter); - setPage(1); - }} - /> -
- - {/* List */} + {/* List — no inline toolbar, all controls are in the plugin toolbar */}
+ {/* Saved Filters Modal */} + {savedFiltersOpen && ( + setSavedFiltersOpen(false)} title="Gespeicherte Filter"> + { + if (criteria.search) setSearch(criteria.search); else setSearch(''); + if (criteria.sortBy) setSortBy(criteria.sortBy); + if (criteria.sortOrder) setSortOrder(criteria.sortOrder); + if (criteria.type) setSelectedFilter(criteria.type as ContactFilter); + setPage(1); + setSavedFiltersOpen(false); + }} + /> + + )}
); } -