29202325a6
- Backend: ContactFolder model (hierarchisch, parent_id, sort_order)
- Migration 0022: contact_folders Tabelle + folder_id FK auf contacts
- CRUD Routes: /api/v1/contact-folders (list, create, update, delete, reorder)
- Move Contact API: /api/v1/contact-folders/contacts/{id}/move
- folder_id Filter in contacts list API
- Frontend: ContactFolderTree mit Baum-Struktur, Drag&Drop, Kontext-Menü
- Kontakt-Personen-Icon statt Ordner-Symbol
- ContactList Items draggable (List/Table/Cards View)
- React Query Hooks für Folder CRUD + Move Contact
- Alle 266 Frontend-Tests passing
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
/**
|
|
* 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 {
|
|
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 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
|
|
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}
|
|
contacts={contacts}
|
|
/>
|
|
</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 — minimal: sort toggle + view toggle + new */}
|
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-secondary-200 bg-white flex-shrink-0">
|
|
{/* Sort */}
|
|
<div className="flex items-center gap-1">
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
|
|
className="text-xs border border-secondary-300 rounded-md px-2 py-1 bg-white text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
aria-label={t('common.sort')}
|
|
>
|
|
{sortOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => handleSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
|
aria-label={sortOrder === 'asc' ? t('common.sortDesc') : t('common.sortAsc')}
|
|
title={sortOrder === 'asc' ? 'Absteigend' : 'Aufsteigend'}
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-secondary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M3 4h13M3 8h9M3 12h5m13 0l-4 4m4-4l-4-4m4 4v6' : 'M3 4h13M3 8h9M3 12h5m13-4v6m0 0l-4-4m4 4l-4 4'} />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* View mode toggle */}
|
|
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
|
|
{viewModeOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setViewMode(opt.value as ContactViewMode)}
|
|
className={clsx(
|
|
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
|
|
viewMode === opt.value
|
|
? 'bg-white text-primary-600 shadow-sm'
|
|
: 'text-secondary-500 hover:text-secondary-700',
|
|
)}
|
|
aria-label={opt.label}
|
|
aria-pressed={viewMode === opt.value}
|
|
>
|
|
{opt.value === '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="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
)}
|
|
{opt.value === 'table' && (
|
|
<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="M4 4h4v4H4V4zm6 0h4v4h-4V4zm6 0h4v4h-4V4zM4 10h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4zM4 16h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4z" />
|
|
</svg>
|
|
)}
|
|
{opt.value === 'cards' && (
|
|
<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="M4 5h6v6H4V5zm10 0h6v6h-6V5zM4 13h6v6H4v-6zm10 0h6v6h-6v-6z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* New contact */}
|
|
<button
|
|
onClick={handleCreate}
|
|
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
|
aria-label={t('contacts.create')}
|
|
title={t('contacts.create')}
|
|
data-testid="contacts-new-btn"
|
|
>
|
|
<svg className="w-4 h-4 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</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}
|
|
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"
|
|
>
|
|
<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>
|
|
);
|
|
}
|