38df597f11
- ImportExportDialog.tsx (neu): Modal lg/xl nach bestehendem ui/Modal-Muster
- Export-Tab: Formatauswahl (csv/xlsx/json), Download, Fehler-Handling
- Import-Tab: 4 Schritte (Datei -> Mapping -> Dry-Run -> Ausführung+Report),
Mapping-Vorschau mit Modul-Heuristik, Background-Job-Polling ab 1000 Zeilen
- i18n: 24 importexport.*-Keys in de.json + en.json (keine hardcoded Strings)
- Integration: ContactsList Toolbar-Button (contacts:read-Gate, Upload-Icon,
entityType=contacts vorgewählt) über bestehendes pluginToolbarStore-Muster
Gates: Vitest 12/12 (routePermissions + importExportDialog), tsc exit 0,
Production-Build exit 0 (vor Commit). 6 Failures in contacts/shell Suiten
als Vorbestand bewiesen (Stash-Test: identisch auf clean HEAD f27f047).
fixes #359 (Phase 2)
809 lines
33 KiB
TypeScript
809 lines
33 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 { 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<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 [savedFiltersOpen, setSavedFiltersOpen] = useState(false);
|
|
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
|
const [filterState, setFilterState] = useState<FilterState>(emptyFilterState);
|
|
const [sortState, setSortState] = useState<SortState>(emptySortState);
|
|
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
|
|
const [viewsOpen, setViewsOpen] = useState(true);
|
|
const [foldersOpen, setFoldersOpen] = useState(true);
|
|
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
|
|
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
|
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
|
|
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(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<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, 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: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
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: <Upload className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
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: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
menuWidth: '180px',
|
|
menuOptions: [
|
|
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
|
|
{ value: 'table', label: t('contacts.viewTable'), icon: <Table2 className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'table', onClick: () => setViewMode('table') },
|
|
{ value: 'cards', label: t('contacts.viewCards'), icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />, 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: (
|
|
<FilterPanel
|
|
filters={filterState}
|
|
onFiltersChange={setFilterState}
|
|
contactType={contactType}
|
|
savedFilters={savedFilters}
|
|
onSaveFilter={handleSaveFilter}
|
|
onLoadFilter={handleLoadFilter}
|
|
onDeleteFilter={handleDeleteFilter}
|
|
/>
|
|
),
|
|
onClick: () => {},
|
|
},
|
|
// SortPanel — SmartSuite-style multi-field sort
|
|
{
|
|
id: 'sort-panel',
|
|
plugin: 'contacts',
|
|
label: 'Sortieren',
|
|
type: 'custom' as const,
|
|
group: 'sort',
|
|
customComponent: (
|
|
<SortPanel
|
|
sortState={sortState}
|
|
onSortChange={setSortState}
|
|
contactType={contactType}
|
|
/>
|
|
),
|
|
onClick: () => {},
|
|
},
|
|
// GroupPanel — SmartSuite-style multi-field grouping
|
|
{
|
|
id: 'group-panel',
|
|
plugin: 'contacts',
|
|
label: 'Gruppierung',
|
|
type: 'custom' as const,
|
|
group: 'group',
|
|
customComponent: (
|
|
<GroupPanel
|
|
groupState={groupState}
|
|
onGroupChange={setGroupState}
|
|
contactType={contactType}
|
|
/>
|
|
),
|
|
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: <Printer className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
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: <ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
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 (
|
|
<div className="flex flex-col h-full" data-testid="contacts-list-page">
|
|
{/* Desktop: three-pane layout with resizable panels */}
|
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
|
{/* Left: Views + Folder tree as accordions — resizable */}
|
|
<ResizablePanel
|
|
initialWidth={240}
|
|
minWidth={150}
|
|
maxWidth={400}
|
|
className="border-r border-secondary-200 bg-white"
|
|
data-testid="contact-folder-pane"
|
|
>
|
|
<div className="flex flex-col h-full overflow-y-auto">
|
|
{/* Accordion 1: Custom Views */}
|
|
<div className="p-0.5">
|
|
<button
|
|
onClick={() => setViewsOpen(!viewsOpen)}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md text-sm font-bold text-secondary-700 hover:bg-secondary-100 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="flex items-center justify-center w-6 h-6 rounded-md bg-primary-600 text-white">
|
|
<LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />
|
|
</span>
|
|
Ansichten
|
|
</span>
|
|
<svg className={`w-3.5 h-3.5 transition-transform ${viewsOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</button>
|
|
{viewsOpen && (
|
|
<div className="px-3 pb-2 space-y-0.5">
|
|
{/* Default view */}
|
|
<button
|
|
onClick={() => {
|
|
setFilterState(emptyFilterState);
|
|
setSortState(emptySortState);
|
|
setGroupState(emptyGroupState);
|
|
setSelectedFilter('all');
|
|
setViewMode('list');
|
|
setMultiSelectFolders([]);
|
|
setActiveViewId(null);
|
|
setSearch('');
|
|
setPage(1);
|
|
}}
|
|
className={`
|
|
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors
|
|
${!filterState.conditions.length && !sortState.conditions.length && !groupState.conditions.length && selectedFilter === 'all'
|
|
? 'bg-primary-100 text-primary-700 font-medium'
|
|
: 'text-secondary-600 hover:bg-secondary-100'}
|
|
`}
|
|
>
|
|
<List className="w-3.5 h-3.5" strokeWidth={2} />
|
|
Standardansicht
|
|
</button>
|
|
{/* Placeholder for saved custom views */}
|
|
{savedViews.length === 0 ? (
|
|
<div className="px-2 py-3 text-center">
|
|
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p>
|
|
<button
|
|
onClick={handleSaveView}
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
Aktuelle Ansicht speichern
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{savedViews.map((view) => (
|
|
<button
|
|
key={view.id}
|
|
onClick={() => applySavedView(view)}
|
|
className={`
|
|
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors group
|
|
${activeViewId === view.id ? 'bg-primary-100 text-primary-700 font-medium' : 'text-secondary-600 hover:bg-secondary-100'}
|
|
`}
|
|
>
|
|
<Bookmark className="w-3.5 h-3.5 flex-shrink-0" strokeWidth={2} />
|
|
<span className="flex-1 truncate">{view.name}</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); deleteSavedView(view.id); }}
|
|
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 transition-opacity"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={handleSaveView}
|
|
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
Aktuelle Ansicht speichern
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Accordion 2: Folders */}
|
|
<div className="flex-1 p-0.5">
|
|
<button
|
|
onClick={() => setFoldersOpen(!foldersOpen)}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md text-sm font-bold text-secondary-700 hover:bg-secondary-100 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="flex items-center justify-center w-6 h-6 rounded-md bg-primary-600 text-white">
|
|
<Bookmark className="w-3.5 h-3.5" strokeWidth={2} />
|
|
</span>
|
|
Ordner
|
|
</span>
|
|
<svg className={`w-3.5 h-3.5 transition-transform ${foldersOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</button>
|
|
{foldersOpen && (
|
|
<div className="px-3 pb-3">
|
|
<ContactFolderTree
|
|
selectedFilter={selectedFilter}
|
|
onSelect={handleSelectFilter}
|
|
tags={allTags}
|
|
contacts={contacts}
|
|
multiSelectFolders={multiSelectFolders}
|
|
onMultiSelectChange={setMultiSelectFolders}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
{/* Middle: Contact list — resizable */}
|
|
<ResizablePanel
|
|
initialWidth={600}
|
|
minWidth={300}
|
|
maxWidth={1200}
|
|
className="border-r border-secondary-200 bg-white"
|
|
data-testid="contact-list-pane"
|
|
>
|
|
{/* List — no inline toolbar, all controls are in the plugin toolbar */}
|
|
<div className="flex-1 min-h-0" id="contacts-table">
|
|
<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}
|
|
sortState={sortState}
|
|
onSortStateChange={setSortState}
|
|
groupState={groupState}
|
|
groupedContacts={groupedContacts}
|
|
selectedContactIds={selectedContactIds}
|
|
onSelectionChange={setSelectedContactIds}
|
|
onBulkDelete={handleBulkDelete}
|
|
onBulkAssignFolder={handleBulkAssignFolder}
|
|
onBulkAddTags={handleBulkAddTags}
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
{/* Right: Detail panel — not resizable, limited width */}
|
|
<ResizablePanel
|
|
resizable={false}
|
|
initialWidth={400}
|
|
maxWidth={500}
|
|
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"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
<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}
|
|
sortState={sortState}
|
|
onSortStateChange={setSortState}
|
|
groupState={groupState}
|
|
groupedContacts={groupedContacts}
|
|
selectedContactIds={selectedContactIds}
|
|
onSelectionChange={setSelectedContactIds}
|
|
onBulkDelete={handleBulkDelete}
|
|
onBulkAssignFolder={handleBulkAssignFolder}
|
|
onBulkAddTags={handleBulkAddTags}
|
|
/>
|
|
</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"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
<span className="text-sm font-medium">{t('common.back')}</span>
|
|
</button>
|
|
</div>
|
|
<ContactDetail
|
|
contact={selectedContact ?? null}
|
|
loading={loadingDetail}
|
|
onEdit={handleEdit}
|
|
onDeleted={handleDeleted}
|
|
dataTestId="contact-detail-mobile"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Saved Filters Modal */}
|
|
{savedFiltersOpen && (
|
|
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title="Gespeicherte Filter">
|
|
<SavedFilters
|
|
entityType="contacts"
|
|
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
|
|
onLoadFilter={(criteria) => {
|
|
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);
|
|
}}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Save View Dialog */}
|
|
<SaveViewDialog
|
|
open={saveViewDialogOpen}
|
|
onClose={() => 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) */}
|
|
<ImportExportDialog
|
|
open={importExportOpen}
|
|
onClose={() => setImportExportOpen(false)}
|
|
entityType="contacts"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|