/** * 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, Upload, X } from 'lucide-react'; import { ImportExportDialog } from '@/components/importexport/ImportExportDialog'; 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 { useSavedViews, useCreateSavedView, useDeleteSavedView } from '@/api/savedViews'; import { useSavedFilters as useSavedFiltersApi, useCreateSavedFilter, useDeleteSavedFilter } from '@/api/savedFilters'; import { useUnifiedContacts, useUnifiedContact, useDeleteUnifiedContact, useUpdateUnifiedContact, type UnifiedContact, } from '@/api/hooks'; import { useContactFolders } from '@/api/contacts'; import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions'; import { usePermission } from '@/hooks/usePermission'; import { useAuthStore } from '@/store/authStore'; const PAGE_SIZE = 25; export function ContactsListPage() { const { t } = useTranslation(); const { hasPermission } = usePermission(); const authUser = useAuthStore((state) => state.user); const canAccess = (perm: string): boolean => { if (!authUser?.permissions && authUser?.is_system_admin === undefined) return true; return hasPermission(perm); }; const [selectedFilter, setSelectedFilter] = useState('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('list'); const [selectedContactId, setSelectedContactId] = useState(null); const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [savedFiltersOpen, setSavedFiltersOpen] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [filterState, setFilterState] = useState(emptyFilterState); const [sortState, setSortState] = useState(emptySortState); const [groupState, setGroupState] = useState(emptyGroupState); const [viewsOpen, setViewsOpen] = useState(true); const [foldersOpen, setFoldersOpen] = useState(true); const [multiSelectFolders, setMultiSelectFolders] = useState([]); const [activeViewId, setActiveViewId] = useState(null); const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false); const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); const openWindow = useWindowStore((s) => s.openWindow); const closeWindow = useWindowStore((s) => s.closeWindow); // Bulk action mutations const deleteContactMut = useDeleteUnifiedContact(); const updateContactMut = useUpdateUnifiedContact(); const { data: folders } = useContactFolders(); const { data: customFieldDefs } = useCustomFieldDefinitions('contact'); // Build custom field defs for filter/sort/group functions const customDefsForFunctions = useMemo(() => { if (!customFieldDefs || !customFieldDefs.items || customFieldDefs.items.length === 0) return undefined; return customFieldDefs.items .filter((def) => def.is_active) .map((def) => ({ key: `custom.${def.name}`, label: def.label || def.name, type: (def.field_type === 'number' ? 'number' : def.field_type === 'date' ? 'date' : (def.field_type === 'select' || def.field_type === 'multiselect') ? 'select' : 'text') as any, group: 'custom' as any, options: (def.field_type === 'select' || def.field_type === 'multiselect') && def.options ? def.options.map((opt: string) => ({ value: opt, label: opt })) : undefined, })); }, [customFieldDefs]); // Saved views from API (durable, per-user) const { data: apiSavedViews } = useSavedViews('contacts'); const createViewMut = useCreateSavedView(); const deleteViewMut = useDeleteSavedView(); const savedViews = apiSavedViews ?? []; // Saved filters from API (durable, per-user) const { data: apiSavedFilters } = useSavedFiltersApi('contacts'); const createFilterMut = useCreateSavedFilter(); const deleteFilterMut = useDeleteSavedFilter(); // Map API saved filters to FilterPanel SavedFilter format const savedFilters: SavedFilter[] = (apiSavedFilters ?? []).map((f) => ({ id: f.id, name: f.name, filterState: f.filter_criteria as FilterState, })); // 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(); 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, customDefsForFunctions); // Apply SortPanel sorting result = applySorting(result, sortState, customDefsForFunctions); return result; }, [contacts, tagFilter, filterState, sortState, multiSelectFolders]); // Apply GroupPanel grouping const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState, customDefsForFunctions), [filteredContacts, groupState, customDefsForFunctions]); // 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'); }, []); // Import/Export dialog (W4a #359) const [importExportOpen, setImportExportOpen] = useState(false); // Handle create const handleCreate = useCallback(() => { const windowId = openWindow({ title: t('contacts.create'), type: 'contact-create', component: ContactEditForm, componentProps: { onClose: () => closeWindow(windowId), onSaved: handleSaved, }, }); }, [openWindow, closeWindow, t, handleSaved]); // Handle edit const handleEdit = useCallback(() => { if (selectedContact) { const windowId = openWindow({ title: t('contacts.edit'), type: 'contact-edit', component: ContactEditForm, componentProps: { contact: selectedContact, onClose: () => closeWindow(windowId), onSaved: handleSaved, }, }); } }, [selectedContact, openWindow, closeWindow, t, handleSaved]); // Handle delete (from detail) const handleDeleted = useCallback(() => { setSelectedContactId(null); setActiveView('list'); }, []); // Bulk delete handler (Feature 2) const handleBulkDelete = useCallback((ids: string[]) => { ids.forEach((id) => { deleteContactMut.mutate({ id }); }); setSelectedContactIds(new Set()); }, [deleteContactMut]); // Bulk assign folder handler (Feature 2) const handleBulkAssignFolder = useCallback((ids: string[], folderId: string) => { ids.forEach((id) => { updateContactMut.mutate({ id, data: { folder_id: folderId } }); }); setSelectedContactIds(new Set()); }, [updateContactMut]); // Bulk add tags handler (Feature 2) const handleBulkAddTags = useCallback((ids: string[], tags: string[]) => { ids.forEach((id) => { const contact = contacts.find((c) => c.id === id); const existingTags = contact?.tags ? contact.tags.split(',').map((t) => t.trim()).filter(Boolean) : []; const newTags = [...new Set([...existingTags, ...tags])]; updateContactMut.mutate({ id, data: { tags: newTags.join(', ') } }); }); setSelectedContactIds(new Set()); }, [updateContactMut, contacts]); // Save current view configuration as a custom view — opens dialog const handleSaveView = useCallback(() => { setSaveViewDialogOpen(true); }, []); // Actually save the view with selection — via API const handleSaveViewConfirm = useCallback((name: string, selection: SaveViewSelection) => { const viewConfig = { filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders, selection, }; createViewMut.mutate({ name, entity_type: 'contacts', view_config: viewConfig }, { onSuccess: (data) => setActiveViewId(data.id), }); }, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders, createViewMut]); // Apply a saved custom view — only selected components (from API view_config) const applySavedView = useCallback((view: { id: string; view_config: any }) => { const cfg = view.view_config; if (!cfg) return; if (cfg.selection?.folder) { setSelectedFilter(cfg.selectedFilter); setMultiSelectFolders(cfg.multiSelectFolders || []); } if (cfg.selection?.viewMode) setViewMode(cfg.viewMode); if (cfg.selection?.filter) setFilterState({ ...cfg.filterState }); if (cfg.selection?.group) setGroupState({ ...cfg.groupState }); if (cfg.selection?.sort) setSortState({ ...cfg.sortState }); setActiveViewId(view.id); setPage(1); }, []); // Delete a saved custom view — via API const deleteSavedView = useCallback((id: string) => { deleteViewMut.mutate(id); if (activeViewId === id) setActiveViewId(null); }, [activeViewId, deleteViewMut]); // Save current filter configuration — via API const handleSaveFilter = useCallback((name: string, filterState: FilterState) => { createFilterMut.mutate({ name, entity_type: 'contacts', filter_criteria: filterState as any }); }, [createFilterMut]); // Load a saved filter const handleLoadFilter = useCallback((filterState: FilterState) => { setFilterState({ ...filterState, conditions: filterState.conditions.map((c) => ({ ...c })) }); setPage(1); }, []); // Delete a saved filter — via API const handleDeleteFilter = useCallback((id: string) => { deleteFilterMut.mutate(id); }, [deleteFilterMut]); // 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 ...(canAccess('contacts:write') ? [{ id: 'new', plugin: 'contacts', label: t('contacts.create'), type: 'button' as const, group: 'create', icon: , onClick: handleCreate, }] : []), // Import/Export dialog (W4a #359) ...(canAccess('contacts:read') ? [{ id: 'import-export', plugin: 'contacts', label: t('importexport.dialogTitle'), type: 'button' as const, group: 'create', icon: , onClick: () => setImportExportOpen(true), }] : []), // View mode dropdown (list / table / cards) { id: 'view-mode', plugin: 'contacts', label: t('common.view', 'Ansicht'), type: 'dropdown' as const, group: 'view', icon: , menuWidth: '180px', menuOptions: [ { value: 'list', label: t('contacts.viewList'), icon: , active: viewMode === 'list', onClick: () => setViewMode('list') }, { value: 'table', label: t('contacts.viewTable'), icon: , active: viewMode === 'table', onClick: () => setViewMode('table') }, { value: 'cards', label: t('contacts.viewCards'), icon: , 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: ( ), onClick: () => {}, }, // SortPanel — SmartSuite-style multi-field sort { id: 'sort-panel', plugin: 'contacts', label: 'Sortieren', type: 'custom' as const, group: 'sort', customComponent: ( ), onClick: () => {}, }, // GroupPanel — SmartSuite-style multi-field grouping { id: 'group-panel', plugin: 'contacts', label: 'Gruppierung', type: 'custom' as const, group: 'group', customComponent: ( ), onClick: () => {}, }, // Print — icon only, right side ...(canAccess('contacts:read') ? [{ id: 'print', plugin: 'contacts', label: t('common.print', 'Drucken'), type: 'button' as const, group: 'zz-right', icon: , 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: , 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, saveViewDialogOpen]); return (
{/* Desktop: three-pane layout with resizable panels */}
{/* Left: Views + Folder tree as accordions — resizable */}
{/* Accordion 1: Custom Views */}
{viewsOpen && (
{/* Default view */} {/* Placeholder for saved custom views */} {savedViews.length === 0 ? (

Keine benutzerdefinierten Ansichten

) : ( <> {savedViews.map((view) => ( ))} )}
)}
{/* Accordion 2: Folders */}
{foldersOpen && (
)}
{/* Middle: Contact list — resizable */} {/* List — no inline toolbar, all controls are in the plugin toolbar */}
{/* Right: Detail panel — not resizable, limited width */}
{/* Mobile: single-pane view switching */}
{/* View 1: Folder tree */} {activeView === 'folders' && (
)} {/* View 2: Contact list */} {activeView === 'list' && (
handleSearch(e.target.value)} placeholder={t('contacts.searchPlaceholder')} aria-label={t('common.search')} />
)} {/* View 3: Contact detail */} {activeView === 'detail' && (
)}
{/* 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); }} /> )} {/* Save View Dialog */} 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} /> {/* Import/Export Dialog (W4a #359) */} setImportExportOpen(false)} entityType="contacts" />
); }