Files
leocrm/frontend/src/pages/ContactsList.tsx
T

693 lines
28 KiB
TypeScript
Raw Normal View History

/**
* Contacts page — unified 3-column layout (folder tree + list + detail).
* Replaces old separate Contacts and Company pages.
* Uses ResizablePanel for drag-to-resize columns, like Mail.tsx.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { Input } from '@/components/ui/Input';
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';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditForm } from '@/components/contacts/ContactEditForm';
import { useWindowStore } from '@/store/windowStore';
import { SavedFilters } from '@/components/SavedFilters';
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
import { TagSelector } from '@/components/tags/TagSelector';
import type { Tag } from '@/api/tags';
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2, X } from 'lucide-react';
import { FilterPanel, applyFilters, emptyFilterState, type FilterState, type SavedFilter } from '@/components/contacts/FilterPanel';
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
import { SaveViewDialog, type SaveViewSelection } from '@/components/contacts/SaveViewDialog';
import {
useUnifiedContacts,
useUnifiedContact,
type UnifiedContact,
} from '@/api/hooks';
const PAGE_SIZE = 25;
export function ContactsListPage() {
const { t } = useTranslation();
const [selectedFilter, setSelectedFilter] = useState<ContactFilter>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState('displayname');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
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 [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [filterState, setFilterState] = useState<FilterState>(emptyFilterState);
const [sortState, setSortState] = useState<SortState>(emptySortState);
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
const [viewsOpen, setViewsOpen] = useState(true);
const [foldersOpen, setFoldersOpen] = useState(true);
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>([]);
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
const openWindow = useWindowStore((s) => s.openWindow);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
// Derive type filter from selectedFilter
const contactType = useMemo(() => {
if (selectedFilter === 'company') return 'company';
if (selectedFilter === 'person') return 'person';
return undefined;
}, [selectedFilter]);
// Derive folder filter
const folderId = useMemo(() => {
if (selectedFilter.startsWith('folder:')) return selectedFilter.slice(7);
return undefined;
}, [selectedFilter]);
// Derive tag filter
const tagFilter = useMemo(() => {
if (selectedFilter.startsWith('tag:')) return selectedFilter.slice(4);
return undefined;
}, [selectedFilter]);
// Fetch contacts list
const { data, isLoading } = useUnifiedContacts(
page,
PAGE_SIZE,
debouncedSearch || undefined,
contactType,
sortBy,
sortOrder,
folderId,
);
// Fetch selected contact detail (with contact_persons)
const { data: selectedContact, isLoading: loadingDetail } = useUnifiedContact(selectedContactId || undefined);
const contacts = data?.items ?? [];
const total = data?.total ?? 0;
// Derive tags from all loaded contacts (for folder tree)
const allTags = useMemo(() => {
const tagSet = new Set<string>();
contacts.forEach((c) => {
if (c.tags) {
c.tags.split(',').forEach((tag) => {
const trimmed = tag.trim();
if (trimmed) tagSet.add(trimmed);
});
}
});
return Array.from(tagSet).sort();
}, [contacts]);
// Filter by tag client-side if tag filter is active, then apply FilterPanel conditions
const filteredContacts = useMemo(() => {
let result = contacts;
// Multi-select folder filter — show contacts from multiple folders
if (multiSelectFolders.length > 0) {
result = result.filter((c) => c.folder_id != null && multiSelectFolders.includes(c.folder_id));
} else if (tagFilter) {
result = result.filter((c) => {
if (!c.tags) return false;
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
});
}
// Apply FilterPanel conditions
result = applyFilters(result, filterState);
// Apply SortPanel sorting
result = applySorting(result, sortState);
return result;
}, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
// Apply GroupPanel grouping
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]);
// Handle folder selection
const handleSelectFilter = useCallback((filter: ContactFilter) => {
setSelectedFilter(filter);
setPage(1);
setSelectedContactId(null);
setActiveView('list');
}, []);
// Handle contact selection
const handleSelectContact = useCallback((contact: UnifiedContact) => {
setSelectedContactId(contact.id);
setActiveView('detail');
}, []);
// Handle sort change
const handleSortChange = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc') => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
}, []);
// Handle search
const handleSearch = useCallback((query: string) => {
setSearch(query);
setPage(1);
}, []);
// Handle edit modal saved
const handleSaved = useCallback((id: string) => {
setSelectedContactId(id);
setActiveView('detail');
}, []);
// Handle create
const handleCreate = useCallback(() => {
openWindow({
title: t('contacts.create'),
type: 'contact-create',
component: ContactEditForm,
componentProps: {
onClose: () => {},
onSaved: handleSaved,
},
});
}, [openWindow, t, handleSaved]);
// Handle edit
const handleEdit = useCallback(() => {
if (selectedContact) {
openWindow({
title: t('contacts.edit'),
type: 'contact-edit',
component: ContactEditForm,
componentProps: {
contact: selectedContact,
onClose: () => {},
onSaved: handleSaved,
},
});
}
}, [selectedContact, openWindow, t, handleSaved]);
// Handle delete (from detail)
const handleDeleted = useCallback(() => {
setSelectedContactId(null);
setActiveView('list');
}, []);
// Save current view configuration as a custom view — opens dialog
const handleSaveView = useCallback(() => {
setSaveViewDialogOpen(true);
}, []);
// Actually save the view with selection
const handleSaveViewConfirm = useCallback((name: string, selection: SaveViewSelection) => {
const id = `view-${Date.now()}`;
setSavedViews((prev) => [...prev, {
id,
name,
filterState: { ...filterState },
sortState: { ...sortState },
groupState: { ...groupState },
selectedFilter,
viewMode,
multiSelectFolders: [...multiSelectFolders],
selection,
}]);
setActiveViewId(id);
}, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders]);
// Apply a saved custom view — only selected components
const applySavedView = useCallback((view: { id: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }) => {
if (view.selection.folder) {
setSelectedFilter(view.selectedFilter);
setMultiSelectFolders(view.multiSelectFolders || []);
}
if (view.selection.viewMode) setViewMode(view.viewMode);
if (view.selection.filter) setFilterState({ ...view.filterState });
if (view.selection.group) setGroupState({ ...view.groupState });
if (view.selection.sort) setSortState({ ...view.sortState });
setActiveViewId(view.id);
setPage(1);
}, []);
// Delete a saved custom view
const deleteSavedView = useCallback((id: string) => {
setSavedViews((prev) => prev.filter((v) => v.id !== id));
if (activeViewId === id) setActiveViewId(null);
}, [activeViewId]);
// Save current filter configuration
const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {
const id = `filter-${Date.now()}`;
setSavedFilters((prev) => [...prev, { id, name, filterState: { ...filterState, conditions: filterState.conditions.map((c) => ({ ...c })) } }]);
}, []);
// Load a saved filter
const handleLoadFilter = useCallback((filterState: FilterState) => {
setFilterState({ ...filterState, conditions: filterState.conditions.map((c) => ({ ...c })) });
setPage(1);
}, []);
// Delete a saved filter
const handleDeleteFilter = useCallback((id: string) => {
setSavedFilters((prev) => prev.filter((f) => f.id !== id));
}, []);
// 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 = [
// New contact — ganz links
{
id: 'new',
plugin: 'contacts',
label: t('contacts.create'),
type: 'button' as const,
group: 'create',
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: handleCreate,
},
// View mode dropdown (list / table / cards)
{
id: 'view-mode',
plugin: 'contacts',
label: t('common.view', 'Ansicht'),
type: 'dropdown' as const,
group: 'view',
icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
menuWidth: '180px',
menuOptions: [
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
{ value: 'table', label: t('contacts.viewTable'), icon: <Table2 className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'table', onClick: () => setViewMode('table') },
{ value: 'cards', label: t('contacts.viewCards'), icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'cards', onClick: () => setViewMode('cards') },
],
onClick: () => {},
},
// Search
{
id: 'search',
plugin: 'contacts',
label: t('common.search'),
type: 'search' as const,
group: 'search',
searchPlaceholder: t('contacts.searchPlaceholder'),
onSearch: handleSearch,
onClick: () => {},
},
// FilterPanel — SmartSuite-style multi-condition filter
{
id: 'filter-panel',
plugin: 'contacts',
label: 'Filter',
type: 'custom' as const,
group: 'filter',
customComponent: (
<FilterPanel
filters={filterState}
onFiltersChange={setFilterState}
contactType={contactType}
savedFilters={savedFilters}
onSaveFilter={handleSaveFilter}
onLoadFilter={handleLoadFilter}
onDeleteFilter={handleDeleteFilter}
/>
),
onClick: () => {},
},
// SortPanel — SmartSuite-style multi-field sort
{
id: 'sort-panel',
plugin: 'contacts',
label: 'Sortieren',
type: 'custom' as const,
group: 'sort',
customComponent: (
<SortPanel
sortState={sortState}
onSortChange={setSortState}
contactType={contactType}
/>
),
onClick: () => {},
},
// GroupPanel — SmartSuite-style multi-field grouping
{
id: 'group-panel',
plugin: 'contacts',
label: 'Gruppierung',
type: 'custom' as const,
group: 'group',
customComponent: (
<GroupPanel
groupState={groupState}
onGroupChange={setGroupState}
contactType={contactType}
/>
),
onClick: () => {},
},
// 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),
},
// Print — icon only, right side
{
id: 'print',
plugin: 'contacts',
label: t('common.print', 'Drucken'),
type: 'button' as const,
group: 'zz-right',
icon: <Printer className="w-3.5 h-3.5" strokeWidth={2} />,
iconOnly: true,
onClick: () => window.print(),
},
// Open standalone — icon only, right side
{
id: 'open-standalone',
plugin: 'contacts',
label: 'In neuem Fenster',
type: 'button' as const,
group: 'zz-right',
icon: <ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />,
iconOnly: true,
onClick: () => window.open('/contacts-standalone', '_blank', 'width=1200,height=800'),
},
];
registerItems('contacts', items);
return () => unregisterPlugin('contacts');
}, [handleSearch, handleCreate, handleSelectFilter, handleSaveView, applySavedView, deleteSavedView, handleSaveViewConfirm, handleSaveFilter, handleLoadFilter, handleDeleteFilter, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState, savedViews, activeViewId, multiSelectFolders, savedFilters, saveViewDialogOpen]);
return (
<div className="flex flex-col h-full" data-testid="contacts-list-page">
{/* Desktop: three-pane layout with resizable panels */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* Left: Views + Folder tree as accordions — resizable */}
<ResizablePanel
initialWidth={240}
minWidth={150}
maxWidth={400}
className="border-r border-secondary-200 bg-white"
data-testid="contact-folder-pane"
>
<div className="flex flex-col h-full overflow-y-auto">
{/* Accordion 1: Custom Views */}
<div className="p-0.5">
<button
onClick={() => setViewsOpen(!viewsOpen)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md text-sm font-bold text-secondary-700 hover:bg-secondary-100 transition-colors"
>
<span className="flex items-center gap-1.5">
<span className="flex items-center justify-center w-6 h-6 rounded-md bg-primary-600 text-white">
<LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />
</span>
Ansichten
</span>
<svg className={`w-3.5 h-3.5 transition-transform ${viewsOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{viewsOpen && (
<div className="px-3 pb-2 space-y-0.5">
{/* Default view */}
<button
onClick={() => {
setFilterState(emptyFilterState);
setSortState(emptySortState);
setGroupState(emptyGroupState);
setSelectedFilter('all');
setViewMode('list');
}}
className={`
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors
${!filterState.conditions.length && !sortState.conditions.length && !groupState.conditions.length && selectedFilter === 'all'
? 'bg-primary-100 text-primary-700 font-medium'
: 'text-secondary-600 hover:bg-secondary-100'}
`}
>
<List className="w-3.5 h-3.5" strokeWidth={2} />
Standardansicht
</button>
{/* Placeholder for saved custom views */}
{savedViews.length === 0 ? (
<div className="px-2 py-3 text-center">
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p>
<button
onClick={handleSaveView}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
>
<Plus className="w-3 h-3" />
Aktuelle Ansicht speichern
</button>
</div>
) : (
<>
{savedViews.map((view) => (
<button
key={view.id}
onClick={() => applySavedView(view)}
className={`
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors group
${activeViewId === view.id ? 'bg-primary-100 text-primary-700 font-medium' : 'text-secondary-600 hover:bg-secondary-100'}
`}
>
<Bookmark className="w-3.5 h-3.5 flex-shrink-0" strokeWidth={2} />
<span className="flex-1 truncate">{view.name}</span>
<button
onClick={(e) => { e.stopPropagation(); deleteSavedView(view.id); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</button>
))}
<button
onClick={handleSaveView}
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
>
<Plus className="w-3 h-3" />
Aktuelle Ansicht speichern
</button>
</>
)}
</div>
)}
</div>
{/* Accordion 2: Folders */}
<div className="flex-1 p-0.5">
<button
onClick={() => setFoldersOpen(!foldersOpen)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md text-sm font-bold text-secondary-700 hover:bg-secondary-100 transition-colors"
>
<span className="flex items-center gap-1.5">
<span className="flex items-center justify-center w-6 h-6 rounded-md bg-primary-600 text-white">
<Bookmark className="w-3.5 h-3.5" strokeWidth={2} />
</span>
Ordner
</span>
<svg className={`w-3.5 h-3.5 transition-transform ${foldersOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{foldersOpen && (
<div className="px-3 pb-3">
<ContactFolderTree
selectedFilter={selectedFilter}
onSelect={handleSelectFilter}
tags={allTags}
contacts={contacts}
multiSelectFolders={multiSelectFolders}
onMultiSelectChange={setMultiSelectFolders}
/>
</div>
)}
</div>
</div>
</ResizablePanel>
{/* Middle: Contact list — resizable */}
<ResizablePanel
initialWidth={400}
minWidth={250}
maxWidth={600}
className="border-r border-secondary-200 bg-white"
data-testid="contact-list-pane"
>
{/* List — no inline toolbar, all controls are in the plugin toolbar */}
<div className="flex-1 min-h-0" id="contacts-table">
<ContactList
contacts={filteredContacts}
selectedContactId={selectedContactId}
onSelectContact={handleSelectContact}
loading={isLoading}
currentPage={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setPage}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</div>
</ResizablePanel>
{/* Right: Detail panel — flex-1, not resizable */}
<ResizablePanel
resizable={false}
className="bg-white"
data-testid="contact-detail-pane"
>
<ContactDetail
contact={selectedContact ?? null}
loading={loadingDetail}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</ResizablePanel>
</div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
{/* View 1: Folder tree */}
{activeView === 'folders' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
<div className="p-3">
<ContactFolderTree
selectedFilter={selectedFilter}
onSelect={handleSelectFilter}
tags={allTags}
contacts={contacts}
/>
</div>
</div>
)}
{/* View 2: Contact list */}
{activeView === 'list' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('folders')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('common.back')}
data-testid="mobile-back-to-folders"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<span className="text-sm font-medium">{t('contacts.allContacts')}</span>
</button>
</div>
<div className="px-3 py-2 border-b border-secondary-200">
<Input
type="search"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('contacts.searchPlaceholder')}
aria-label={t('common.search')}
/>
</div>
<ContactList
contacts={filteredContacts}
selectedContactId={selectedContactId}
onSelectContact={handleSelectContact}
loading={isLoading}
currentPage={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setPage}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</div>
)}
{/* View 3: Contact detail */}
{activeView === 'detail' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('list')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('common.back')}
data-testid="mobile-back-to-list"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<span className="text-sm font-medium">{t('common.back')}</span>
</button>
</div>
<ContactDetail
contact={selectedContact ?? null}
loading={loadingDetail}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</div>
)}
</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>
)}
{/* Save View Dialog */}
<SaveViewDialog
open={saveViewDialogOpen}
onClose={() => setSaveViewDialogOpen(false)}
onSave={handleSaveViewConfirm}
hasFilter={filterState.conditions.length > 0}
hasGroup={groupState.conditions.length > 0}
hasSort={sortState.conditions.length > 0}
hasFolder={selectedFilter !== 'all' || multiSelectFolders.length > 0}
/>
</div>
);
}