Frontend: unified 3-column contacts page (folder tree | list/table/cards | detail), all Rentman fields, ContactPerson CRUD, removed old Contact/Company pages
This commit is contained in:
+358
-117
@@ -1,148 +1,389 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
/**
|
||||
* 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 { ColumnDef } from '@tanstack/react-table';
|
||||
import { useContacts, useDeleteContact, Contact } from '@/api/hooks';
|
||||
import { DataGrid } from '@/components/shared/DataGrid';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
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 navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [selectedFilter, setSelectedFilter] = useState<ContactFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||
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);
|
||||
|
||||
const { data, isLoading } = useContacts(page, pageSize, debouncedSearch);
|
||||
const deleteMutation = useDeleteContact();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Contact, any>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
header: t('contacts.fullName'),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={`${row.original.first_name} ${row.original.last_name}`} size="sm" />
|
||||
<span className="font-medium text-primary-700">
|
||||
{row.original.first_name} {row.original.last_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: t('contacts.email'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
|
||||
{info.getValue()}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: t('contacts.phone'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'position',
|
||||
header: t('contacts.position'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
// 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,
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
toast.success(t('contacts.deleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
// Fetch selected contact detail (with contact_persons)
|
||||
const { data: selectedContact, isLoading: loadingDetail } = useUnifiedContact(selectedContactId || undefined);
|
||||
|
||||
const contacts = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const isEmpty = !isLoading && contacts.length === 0 && !debouncedSearch;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t('contacts.emptyTitle')}
|
||||
description={t('contacts.emptyDescription')}
|
||||
action={
|
||||
<Button onClick={() => navigate('/contacts/new')}>
|
||||
{t('contacts.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||||
<Button onClick={() => navigate('/contacts/new')}>
|
||||
{t('contacts.create')}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={contacts}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(row) => navigate(`/contacts/${row.id}`)}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('common.noResults')}
|
||||
testId="contacts-grid"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('contacts.deleteConfirmTitle')}
|
||||
message={t('contacts.deleteConfirm')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
{/* Edit/Create Modal */}
|
||||
<ContactEditModal
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
contact={editingContact}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user