fix: ContactFolderTree drag-drop removed, dropdown menu with rename/color/pin/delete; ContactsList toolbar moved to plugin toolbar

This commit is contained in:
Agent Zero
2026-07-27 09:47:22 +02:00
parent b24ac6883f
commit 47dfdfb794
3 changed files with 235 additions and 261 deletions
+151 -140
View File
@@ -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<ContactFilter>('all');
const [search, setSearch] = useState('');
@@ -41,6 +37,7 @@ export function ContactsListPage() {
const [viewMode, setViewMode] = useState<ContactViewMode>('list');
const [selectedContactId, setSelectedContactId] = useState<string | null>(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'
? <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />
: <ArrowUpZA className="w-3.5 h-3.5" strokeWidth={2} />,
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: <List className="w-3.5 h-3.5" strokeWidth={2} />,
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: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
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: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: () => setViewMode('cards'),
},
// Saved filters
{
id: 'saved-filters',
plugin: 'contacts',
label: 'Gespeicherte Filter',
type: 'button' as const,
group: 'actions',
icon: <Bookmark className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: () => setSavedFiltersOpen(true),
},
// New contact
{
id: 'new',
plugin: 'contacts',
label: t('contacts.create'),
type: 'button' as const,
group: 'actions',
icon: (
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
),
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: handleCreate,
},
// Print
{
id: 'print',
plugin: 'contacts',
label: t('common.print', 'Drucken'),
type: 'button' as const,
group: 'actions',
icon: <Printer className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: () => window.print(),
},
// Open standalone
{
id: 'open-standalone',
plugin: 'contacts',
label: 'In neuem Fenster',
type: 'button' as const,
group: 'actions',
icon: (
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
),
icon: <ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />,
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 (
<div className="flex flex-col h-full" data-testid="contacts-list-page">
{/* Error display */}
{/* (errors handled by react-query + toast) */}
{/* Desktop: three-pane layout with resizable panels */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* 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 */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-secondary-200 bg-white flex-shrink-0">
{/* Sort */}
<div className="flex items-center gap-1">
<select
value={sortBy}
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
className="text-xs border border-secondary-300 rounded-md px-2 py-1 bg-white text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('common.sort')}
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => handleSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
aria-label={sortOrder === 'asc' ? t('common.sortDesc') : t('common.sortAsc')}
title={sortOrder === 'asc' ? 'Absteigend' : 'Aufsteigend'}
>
{sortOrder === 'asc' ? <ArrowDownAZ className="w-3.5 h-3.5 text-secondary-600" aria-hidden="true" strokeWidth={2} /> : <ArrowUpZA className="w-3.5 h-3.5 text-secondary-600" aria-hidden="true" strokeWidth={2} />}
</button>
</div>
<div className="flex-1" />
{/* Type filter toggle */}
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5 mr-1">
{[
{ 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) => (
<button
key={opt.value}
onClick={() => handleSelectFilter(opt.value)}
className={clsx(
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
selectedFilter === opt.value
? 'bg-white text-primary-600 shadow-sm'
: 'text-secondary-500 hover:text-secondary-700',
)}
aria-label={opt.label}
aria-pressed={selectedFilter === opt.value}
>
{opt.label}
</button>
))}
</div>
{/* View mode toggle */}
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
{viewModeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setViewMode(opt.value as ContactViewMode)}
className={clsx(
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
viewMode === opt.value
? 'bg-white text-primary-600 shadow-sm'
: 'text-secondary-500 hover:text-secondary-700',
)}
aria-label={opt.label}
aria-pressed={viewMode === opt.value}
>
{opt.value === 'list' && (
<List className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
)}
{opt.value === 'table' && (
<LayoutGrid className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
)}
{opt.value === 'cards' && (
<LayoutGrid className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
)}
</button>
))}
</div>
{/* New contact */}
<button
onClick={handleCreate}
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
aria-label={t('contacts.create')}
title={t('contacts.create')}
data-testid="contacts-new-btn"
>
<Plus className="w-4 h-4 text-primary-600" aria-hidden="true" strokeWidth={2} />
</button>
<PrintButton targetId="contacts-table" />
</div>
{/* Saved Filters */}
<div className="px-3 py-1.5 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
onLoadFilter={(criteria) => {
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);
}}
/>
</div>
{/* List */}
{/* List — no inline toolbar, all controls are in the plugin toolbar */}
<div className="flex-1 min-h-0" id="contacts-table">
<ContactList
contacts={filteredContacts}
@@ -486,7 +481,23 @@ export function ContactsListPage() {
)}
</div>
{/* Saved Filters Modal */}
{savedFiltersOpen && (
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title="Gespeicherte Filter">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
onLoadFilter={(criteria) => {
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);
}}
/>
</Modal>
)}
</div>
);
}