/** * 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 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 { 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 { ContactEditModal } from '@/components/contacts/ContactEditModal'; import { SavedFilters } from '@/components/SavedFilters'; import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react'; import { useUnifiedContacts, useUnifiedContact, type UnifiedContact, } from '@/api/hooks'; const PAGE_SIZE = 25; export function ContactsListPage() { const { t } = useTranslation(); const toast = useToast(); 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 [editModalOpen, setEditModalOpen] = useState(false); const [editingContact, setEditingContact] = useState(null); // 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 const filteredContacts = useMemo(() => { if (!tagFilter) return contacts; return contacts.filter((c) => { if (!c.tags) return false; return c.tags.split(',').map((t) => t.trim()).includes(tagFilter); }); }, [contacts, tagFilter]); // 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 create const handleCreate = useCallback(() => { setEditingContact(null); setEditModalOpen(true); }, []); // Handle edit const handleEdit = useCallback(() => { if (selectedContact) { setEditingContact(selectedContact); setEditModalOpen(true); } }, [selectedContact]); // Handle delete (from detail) const handleDeleted = useCallback(() => { setSelectedContactId(null); setActiveView('list'); }, []); // Handle edit modal saved const handleSaved = useCallback((id: string) => { setSelectedContactId(id); setActiveView('detail'); }, []); // Register toolbar items const registerItems = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); useEffect(() => { const items = [ { id: 'search', plugin: 'contacts', label: t('common.search'), type: 'search' as const, group: 'search', searchPlaceholder: t('contacts.searchPlaceholder'), onSearch: handleSearch, onClick: () => {}, }, { id: 'new', plugin: 'contacts', label: t('contacts.create'), group: 'actions', icon: ( ), onClick: handleCreate, }, ]; 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') }, ]; return (
{/* Error display */} {/* (errors handled by react-query + toast) */} {/* Desktop: three-pane layout with resizable panels */}
{/* Left: Folder tree — resizable */}
{/* Middle: Contact list — resizable */} {/* 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 */}
{/* Right: Detail panel — flex-1, not resizable */}
{/* 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' && (
)}
{/* Edit/Create Modal */} setEditModalOpen(false)} contact={editingContact} onSaved={handleSaved} />
); }