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

391 lines
13 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 { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
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 {
useUnifiedContacts,
useUnifiedContact,
type UnifiedContact,
} from '@/api/hooks';
const PAGE_SIZE = 25;
export function ContactsListPage() {
const { t } = useTranslation();
const toast = useToast();
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 [editModalOpen, setEditModalOpen] = useState(false);
const [editingContact, setEditingContact] = useState<UnifiedContact | null>(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 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,
);
// 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
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: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
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 (
<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 */}
<ResizablePanel
initialWidth={240}
minWidth={150}
maxWidth={400}
className="border-r border-secondary-200 bg-white"
data-testid="contact-folder-pane"
>
<div className="p-3">
<ContactFolderTree
selectedFilter={selectedFilter}
onSelect={handleSelectFilter}
tags={allTags}
/>
</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"
>
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 bg-white flex-shrink-0">
<Input
type="search"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('contacts.searchPlaceholder')}
aria-label={t('common.search')}
className="flex-1"
/>
<Select
value={viewMode}
onChange={(e) => setViewMode(e.target.value as ContactViewMode)}
options={viewModeOptions}
aria-label={t('contacts.viewMode')}
className="w-auto"
/>
<Select
value={sortBy}
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
options={sortOptions}
aria-label={t('common.filter')}
className="w-auto"
/>
<Button size="sm" onClick={handleCreate} data-testid="contacts-new-btn">
{t('contacts.create')}
</Button>
</div>
{/* List */}
<div className="flex-1 min-h-0">
<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}
/>
</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"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<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"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">{t('common.back')}</span>
</button>
</div>
<ContactDetail
contact={selectedContact ?? null}
loading={loadingDetail}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</div>
)}
</div>
{/* Edit/Create Modal */}
<ContactEditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
contact={editingContact}
onSaved={handleSaved}
/>
</div>
);
}