feat: column visibility, bulk actions, custom sort drag-drop, custom fields in filter/sort/group, mobile optimization
This commit is contained in:
@@ -6,9 +6,11 @@ import { Badge } from '@/components/ui/Badge';
|
|||||||
import { Pagination } from '@/components/ui/Pagination';
|
import { Pagination } from '@/components/ui/Pagination';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import type { UnifiedContact } from '@/api/hooks';
|
import type { UnifiedContact } from '@/api/hooks';
|
||||||
|
import { useContactFolders } from '@/api/contacts';
|
||||||
|
import type { ContactFolder } from '@/api/contactFolders';
|
||||||
import type { SortState, SortCondition } from '@/components/contacts/SortPanel';
|
import type { SortState, SortCondition } from '@/components/contacts/SortPanel';
|
||||||
import type { GroupState, GroupedContacts } from '@/components/contacts/GroupPanel';
|
import type { GroupState, GroupedContacts } from '@/components/contacts/GroupPanel';
|
||||||
import { Loader2, ChevronRight, ChevronDown } from 'lucide-react';
|
import { Loader2, ChevronRight, ChevronDown, Settings, GripVertical, Info } from 'lucide-react';
|
||||||
|
|
||||||
export type ContactViewMode = 'list' | 'table' | 'cards';
|
export type ContactViewMode = 'list' | 'table' | 'cards';
|
||||||
|
|
||||||
@@ -315,6 +317,13 @@ export interface ContactListProps {
|
|||||||
onSortStateChange?: (s: SortState) => void;
|
onSortStateChange?: (s: SortState) => void;
|
||||||
groupState?: GroupState;
|
groupState?: GroupState;
|
||||||
groupedContacts?: GroupedContacts[];
|
groupedContacts?: GroupedContacts[];
|
||||||
|
// Bulk selection props
|
||||||
|
selectedContactIds?: Set<string>;
|
||||||
|
onSelectionChange?: (ids: Set<string>) => void;
|
||||||
|
// Bulk action callbacks
|
||||||
|
onBulkDelete?: (ids: string[]) => void;
|
||||||
|
onBulkAssignFolder?: (ids: string[], folderId: string) => void;
|
||||||
|
onBulkAddTags?: (ids: string[], tags: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactList({
|
export function ContactList({
|
||||||
@@ -334,6 +343,11 @@ export function ContactList({
|
|||||||
onSortStateChange,
|
onSortStateChange,
|
||||||
groupState,
|
groupState,
|
||||||
groupedContacts,
|
groupedContacts,
|
||||||
|
selectedContactIds,
|
||||||
|
onSelectionChange,
|
||||||
|
onBulkDelete,
|
||||||
|
onBulkAssignFolder,
|
||||||
|
onBulkAddTags,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -346,6 +360,166 @@ export function ContactList({
|
|||||||
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
|
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
|
||||||
const dragColRef = useRef<string | null>(null);
|
const dragColRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// ── Contact folders for bulk assign (Feature 2) ──
|
||||||
|
const { data: folderData } = useContactFolders();
|
||||||
|
const folderList: ContactFolder[] = folderData ?? [];
|
||||||
|
|
||||||
|
// ── Column visibility dropdown state (Feature 1) ──
|
||||||
|
const [colMenuOpen, setColMenuOpen] = useState(false);
|
||||||
|
const colMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ── Custom sort drag-and-drop state (Feature 3) ──
|
||||||
|
const LS_CUSTOM_ORDER_KEY = 'contact-custom-order';
|
||||||
|
const [customOrder, setCustomOrder] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(LS_CUSTOM_ORDER_KEY);
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const dragRowRef = useRef<string | null>(null);
|
||||||
|
const isCustomSortActive = !sortState || sortState.conditions.length === 0;
|
||||||
|
|
||||||
|
// ── Bulk action bar state (Feature 2) ──
|
||||||
|
const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
|
||||||
|
const [bulkFolderOpen, setBulkFolderOpen] = useState(false);
|
||||||
|
const [bulkTagsInput, setBulkTagsInput] = useState('');
|
||||||
|
const [bulkTagsOpen, setBulkTagsOpen] = useState(false);
|
||||||
|
const bulkFolderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bulkTagsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close column menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!colMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (colMenuRef.current && !colMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setColMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [colMenuOpen]);
|
||||||
|
|
||||||
|
// Close bulk folder dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bulkFolderOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (bulkFolderRef.current && !bulkFolderRef.current.contains(e.target as Node)) {
|
||||||
|
setBulkFolderOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [bulkFolderOpen]);
|
||||||
|
|
||||||
|
// Close bulk tags dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bulkTagsOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (bulkTagsRef.current && !bulkTagsRef.current.contains(e.target as Node)) {
|
||||||
|
setBulkTagsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [bulkTagsOpen]);
|
||||||
|
|
||||||
|
// ── Column visibility toggle (Feature 1) ──
|
||||||
|
const toggleColumnVisibility = useCallback((key: string) => {
|
||||||
|
if (key === 'checkbox') return; // checkbox always visible
|
||||||
|
setVisibleColumns((prev) => {
|
||||||
|
if (prev.includes(key)) {
|
||||||
|
const next = prev.filter((k) => k !== key);
|
||||||
|
localStorage.setItem(LS_VISIBLE_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
} else {
|
||||||
|
const next = [...prev, key];
|
||||||
|
localStorage.setItem(LS_VISIBLE_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Bulk selection handlers (Feature 2) ──
|
||||||
|
const toggleContactSelection = useCallback((contactId: string) => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const next = new Set(selectedContactIds || new Set<string>());
|
||||||
|
if (next.has(contactId)) {
|
||||||
|
next.delete(contactId);
|
||||||
|
} else {
|
||||||
|
next.add(contactId);
|
||||||
|
}
|
||||||
|
onSelectionChange(next);
|
||||||
|
}, [onSelectionChange, selectedContactIds]);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const current = selectedContactIds || new Set<string>();
|
||||||
|
const allSelected = contacts.every((c) => current.has(c.id));
|
||||||
|
if (allSelected) {
|
||||||
|
const next = new Set(current);
|
||||||
|
contacts.forEach((c) => next.delete(c.id));
|
||||||
|
onSelectionChange(next);
|
||||||
|
} else {
|
||||||
|
const next = new Set(current);
|
||||||
|
contacts.forEach((c) => next.add(c.id));
|
||||||
|
onSelectionChange(next);
|
||||||
|
}
|
||||||
|
}, [onSelectionChange, selectedContactIds, contacts]);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
if (onSelectionChange) onSelectionChange(new Set<string>());
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
// ── Custom sort drag-and-drop handlers (Feature 3) ──
|
||||||
|
const applyCustomOrder = useCallback((list: UnifiedContact[]): UnifiedContact[] => {
|
||||||
|
if (!isCustomSortActive || customOrder.length === 0) return list;
|
||||||
|
const orderMap = new Map<string, number>();
|
||||||
|
customOrder.forEach((id, idx) => orderMap.set(id, idx));
|
||||||
|
const ordered = [...list];
|
||||||
|
ordered.sort((a, b) => {
|
||||||
|
const ai = orderMap.has(a.id) ? orderMap.get(a.id)! : Infinity;
|
||||||
|
const bi = orderMap.has(b.id) ? orderMap.get(b.id)! : Infinity;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
}, [isCustomSortActive, customOrder]);
|
||||||
|
|
||||||
|
const handleRowDragStart = useCallback((e: React.DragEvent, contactId: string) => {
|
||||||
|
if (!isCustomSortActive) return;
|
||||||
|
dragRowRef.current = contactId;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', contactId);
|
||||||
|
}, [isCustomSortActive]);
|
||||||
|
|
||||||
|
const handleRowDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isCustomSortActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}, [isCustomSortActive]);
|
||||||
|
|
||||||
|
const handleRowDrop = useCallback((e: React.DragEvent, targetId: string) => {
|
||||||
|
if (!isCustomSortActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const sourceId = dragRowRef.current;
|
||||||
|
if (!sourceId || sourceId === targetId) return;
|
||||||
|
|
||||||
|
const currentContacts = applyCustomOrder(contacts);
|
||||||
|
const sourceIdx = currentContacts.findIndex((c) => c.id === sourceId);
|
||||||
|
const targetIdx = currentContacts.findIndex((c) => c.id === targetId);
|
||||||
|
if (sourceIdx === -1 || targetIdx === -1) return;
|
||||||
|
|
||||||
|
const reordered = [...currentContacts];
|
||||||
|
const [moved] = reordered.splice(sourceIdx, 1);
|
||||||
|
reordered.splice(targetIdx, 0, moved);
|
||||||
|
|
||||||
|
const newOrder = reordered.map((c) => c.id);
|
||||||
|
setCustomOrder(newOrder);
|
||||||
|
localStorage.setItem(LS_CUSTOM_ORDER_KEY, JSON.stringify(newOrder));
|
||||||
|
dragRowRef.current = null;
|
||||||
|
}, [isCustomSortActive, contacts, applyCustomOrder]);
|
||||||
|
|
||||||
// Auto-skip virtualization for small datasets
|
// Auto-skip virtualization for small datasets
|
||||||
const shouldVirtualize = contacts.length >= 50;
|
const shouldVirtualize = contacts.length >= 50;
|
||||||
|
|
||||||
@@ -670,26 +844,48 @@ export function ContactList({
|
|||||||
const renderContactRow = (contact: UnifiedContact) => (
|
const renderContactRow = (contact: UnifiedContact) => (
|
||||||
<tr
|
<tr
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
draggable
|
draggable={isCustomSortActive}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData('text/plain', contact.id);
|
if (isCustomSortActive) {
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
handleRowDragStart(e, contact.id);
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.setData('text/plain', contact.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragOver={isCustomSortActive ? handleRowDragOver : undefined}
|
||||||
|
onDrop={isCustomSortActive ? (e) => handleRowDrop(e, contact.id) : undefined}
|
||||||
onClick={() => onSelectContact(contact)}
|
onClick={() => onSelectContact(contact)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer transition-colors',
|
'cursor-pointer transition-colors',
|
||||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||||
|
isCustomSortActive && 'cursor-grab active:cursor-grabbing',
|
||||||
)}
|
)}
|
||||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
{orderedVisibleColumns.map((col) => (
|
{orderedVisibleColumns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="px-3 py-2 overflow-hidden text-ellipsis whitespace-nowrap"
|
className={clsx('px-3 py-2 overflow-hidden text-ellipsis whitespace-nowrap', isCustomSortActive && col.key !== 'checkbox' && 'flex items-center')}
|
||||||
style={{ width: getColWidth(col.key), maxWidth: getColWidth(col.key) }}
|
style={{ width: getColWidth(col.key), maxWidth: getColWidth(col.key) }}
|
||||||
onClick={col.key === 'checkbox' ? (e) => e.stopPropagation() : undefined}
|
onClick={col.key === 'checkbox' ? (e) => { e.stopPropagation(); toggleContactSelection(contact.id); } : undefined}
|
||||||
>
|
>
|
||||||
{col.render(contact)}
|
{col.key === 'checkbox' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedContactIds?.has(contact.id) || false}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="rounded border-secondary-300"
|
||||||
|
aria-label={getDisplayName(contact)}
|
||||||
|
/>
|
||||||
|
) : isCustomSortActive && col.key === orderedVisibleColumns.find((c) => c.key !== 'checkbox')?.key ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GripVertical className="w-3 h-3 text-secondary-300 flex-shrink-0" />
|
||||||
|
{col.render(contact)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
col.render(contact)
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -753,6 +949,133 @@ export function ContactList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="contact-table-view">
|
<div className="flex flex-col h-full" data-testid="contact-table-view">
|
||||||
|
{/* Bulk action bar (Feature 2) */}
|
||||||
|
{selectedContactIds && selectedContactIds.size > 0 && (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center gap-2 px-3 py-2 bg-primary-600 text-white shadow-md">
|
||||||
|
<span className="text-sm font-medium">{selectedContactIds.size} ausgewählt</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{onBulkDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDeleteConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-red-500 hover:bg-red-600 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onBulkAssignFolder && (
|
||||||
|
<div ref={bulkFolderRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkFolderOpen(!bulkFolderOpen)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Ordner zuweisen ▾
|
||||||
|
</button>
|
||||||
|
{bulkFolderOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30">
|
||||||
|
{folderList.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() => {
|
||||||
|
onBulkAssignFolder(Array.from(selectedContactIds), folder.id);
|
||||||
|
setBulkFolderOpen(false);
|
||||||
|
clearSelection();
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-secondary-100 transition-colors"
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{folderList.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-secondary-400">Keine Ordner</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onBulkAddTags && (
|
||||||
|
<div ref={bulkTagsRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkTagsOpen(!bulkTagsOpen)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Tags hinzufügen ▾
|
||||||
|
</button>
|
||||||
|
{bulkTagsOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bulkTagsInput}
|
||||||
|
onChange={(e) => setBulkTagsInput(e.target.value)}
|
||||||
|
placeholder="tag1, tag2, ..."
|
||||||
|
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const tags = bulkTagsInput.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
if (tags.length > 0) {
|
||||||
|
onBulkAddTags(Array.from(selectedContactIds), tags);
|
||||||
|
setBulkTagsInput('');
|
||||||
|
setBulkTagsOpen(false);
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full mt-1 px-2 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Auswahl aufheben
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk delete confirmation dialog (Feature 2) */}
|
||||||
|
{bulkDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 mb-2">Löschen bestätigen</h3>
|
||||||
|
<p className="text-sm text-secondary-600 mb-4">
|
||||||
|
{selectedContactIds?.size || 0} Kontakt(e) wirklich löschen?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDeleteConfirm(false)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onBulkDelete && selectedContactIds) {
|
||||||
|
onBulkDelete(Array.from(selectedContactIds));
|
||||||
|
setBulkDeleteConfirm(false);
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom sort info indicator (Feature 3) */}
|
||||||
|
{isCustomSortActive && customOrder.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
<span>Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-x-auto overflow-y-auto">
|
<div ref={scrollRef} className="flex-1 overflow-x-auto overflow-y-auto">
|
||||||
<table
|
<table
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
@@ -784,7 +1107,13 @@ export function ContactList({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{col.key === 'checkbox' ? (
|
{col.key === 'checkbox' ? (
|
||||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedContactIds ? contacts.length > 0 && contacts.every((c) => selectedContactIds.has(c.id)) : false}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
className="rounded border-secondary-300"
|
||||||
|
aria-label={t('common.all')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className={clsx(col.sortable && 'cursor-pointer')}>
|
<span className={clsx(col.sortable && 'cursor-pointer')}>
|
||||||
@@ -803,13 +1132,49 @@ export function ContactList({
|
|||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Column visibility settings button (Feature 1) */}
|
||||||
|
<th className="relative px-2 py-2 w-10" style={{ width: 40 }}>
|
||||||
|
<div ref={colMenuRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setColMenuOpen(!colMenuOpen)}
|
||||||
|
title="Spalten verwalten"
|
||||||
|
aria-label="Spalten verwalten"
|
||||||
|
className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{colMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30">
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">Spalten verwalten</div>
|
||||||
|
{ALL_COLUMNS.map((col) => (
|
||||||
|
<label
|
||||||
|
key={col.key}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-secondary-50 cursor-pointer',
|
||||||
|
col.key === 'checkbox' && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleColumns.includes(col.key)}
|
||||||
|
onChange={() => toggleColumnVisibility(col.key)}
|
||||||
|
disabled={col.key === 'checkbox'}
|
||||||
|
className="rounded border-secondary-300"
|
||||||
|
/>
|
||||||
|
<span>{col.label || 'Checkbox'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-100">
|
<tbody className="divide-y divide-secondary-100">
|
||||||
{isGrouped ? (
|
{isGrouped ? (
|
||||||
renderGroupRows(groupTree)
|
renderGroupRows(groupTree)
|
||||||
) : (
|
) : (
|
||||||
contacts.map((contact) => renderContactRow(contact))
|
applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
|
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
|
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||||
|
|
||||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -15,10 +16,10 @@ interface FilterFieldDef {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta';
|
options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta' | 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIELD_DEFS: FilterFieldDef[] = [
|
let FIELD_DEFS: FilterFieldDef[] = [
|
||||||
// General
|
// General
|
||||||
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
||||||
{ key: 'type', label: 'Typ', type: 'select', group: 'general', options: [
|
{ key: 'type', label: 'Typ', type: 'select', group: 'general', options: [
|
||||||
@@ -139,11 +140,16 @@ export const emptyFilterState: FilterState = {
|
|||||||
// ─── Filter logic ─────────────────────────────────────────────────────────────
|
// ─── Filter logic ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getFieldValue(contact: UnifiedContact, field: string): any {
|
function getFieldValue(contact: UnifiedContact, field: string): any {
|
||||||
|
if (field.startsWith('custom.')) {
|
||||||
|
const customField = field.slice(7);
|
||||||
|
return contact.custom?.[customField];
|
||||||
|
}
|
||||||
return (contact as any)[field];
|
return (contact as any)[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean {
|
function matchesCondition(contact: UnifiedContact, cond: FilterCondition, allDefs?: FilterFieldDef[]): boolean {
|
||||||
const def = FIELD_DEFS.find((f) => f.key === cond.field);
|
const defs = allDefs || FIELD_DEFS;
|
||||||
|
const def = defs.find((f) => f.key === cond.field);
|
||||||
if (!def) return true;
|
if (!def) return true;
|
||||||
const val = getFieldValue(contact, cond.field);
|
const val = getFieldValue(contact, cond.field);
|
||||||
const op = cond.operator;
|
const op = cond.operator;
|
||||||
@@ -175,38 +181,39 @@ function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFilters(contacts: UnifiedContact[], filters: FilterState): UnifiedContact[] {
|
export function applyFilters(contacts: UnifiedContact[], filters: FilterState, allDefs?: FilterFieldDef[]): UnifiedContact[] {
|
||||||
if (!filters.conditions.length) return contacts;
|
if (!filters.conditions.length) return contacts;
|
||||||
if (filters.logic === 'AND') {
|
if (filters.logic === 'AND') {
|
||||||
return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond)));
|
return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond, allDefs)));
|
||||||
} else {
|
} else {
|
||||||
return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond)));
|
return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond, allDefs)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
||||||
|
|
||||||
function getOrderedFields(contactType?: 'company' | 'person' | undefined): FilterFieldDef[] {
|
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: FilterFieldDef[]): FilterFieldDef[] {
|
||||||
|
const defs = allDefs || FIELD_DEFS;
|
||||||
if (contactType === 'company') {
|
if (contactType === 'company') {
|
||||||
// Company fields first, then general, then person, then rest
|
// Company fields first, then general, then person, then rest
|
||||||
const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'person'];
|
const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'custom', 'person'];
|
||||||
return [...FIELD_DEFS].sort((a, b) => {
|
return [...defs].sort((a, b) => {
|
||||||
const ai = order.indexOf(a.group || 'general');
|
const ai = order.indexOf(a.group || 'general');
|
||||||
const bi = order.indexOf(b.group || 'general');
|
const bi = order.indexOf(b.group || 'general');
|
||||||
return ai - bi;
|
return ai - bi;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (contactType === 'person') {
|
if (contactType === 'person') {
|
||||||
const order = ['general', 'person', 'address', 'notes', 'meta', 'company', 'finance'];
|
const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company', 'finance'];
|
||||||
return [...FIELD_DEFS].sort((a, b) => {
|
return [...defs].sort((a, b) => {
|
||||||
const ai = order.indexOf(a.group || 'general');
|
const ai = order.indexOf(a.group || 'general');
|
||||||
const bi = order.indexOf(b.group || 'general');
|
const bi = order.indexOf(b.group || 'general');
|
||||||
return ai - bi;
|
return ai - bi;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// All: general first, then alphabetical by group
|
// All: general first, then alphabetical by group
|
||||||
const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta'];
|
const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta', 'custom'];
|
||||||
return [...FIELD_DEFS].sort((a, b) => {
|
return [...defs].sort((a, b) => {
|
||||||
const ai = order.indexOf(a.group || 'general');
|
const ai = order.indexOf(a.group || 'general');
|
||||||
const bi = order.indexOf(b.group || 'general');
|
const bi = order.indexOf(b.group || 'general');
|
||||||
return ai - bi;
|
return ai - bi;
|
||||||
@@ -242,6 +249,34 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
// Fetch custom field definitions (Feature 4)
|
||||||
|
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
|
||||||
|
|
||||||
|
// Merge custom field definitions into FIELD_DEFS
|
||||||
|
const allFieldDefs = useMemo(() => {
|
||||||
|
const baseFields = FIELD_DEFS.filter((f) => !f.key.startsWith('custom.'));
|
||||||
|
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
|
||||||
|
const customFields: FilterFieldDef[] = customFieldDefs.items
|
||||||
|
.filter((def) => def.is_active)
|
||||||
|
.map((def) => {
|
||||||
|
const fieldType: FieldType =
|
||||||
|
def.field_type === 'number' ? 'number' :
|
||||||
|
def.field_type === 'date' ? 'date' :
|
||||||
|
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
|
||||||
|
'text';
|
||||||
|
return {
|
||||||
|
key: `custom.${def.name}`,
|
||||||
|
label: def.label || def.name,
|
||||||
|
type: fieldType,
|
||||||
|
group: 'custom' as const,
|
||||||
|
options: (def.field_type === 'select' || def.field_type === 'multiselect') && def.options
|
||||||
|
? def.options.map((opt) => ({ value: opt, label: opt }))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...baseFields, ...customFields];
|
||||||
|
}, [customFieldDefs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -256,12 +291,14 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (!open && btnRef.current) {
|
if (!open && btnRef.current) {
|
||||||
const rect = btnRef.current.getBoundingClientRect();
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
setPanelPos({ top: rect.bottom + 4, left: rect.left });
|
const panelWidth = Math.min(480, window.innerWidth * 0.9);
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
|
||||||
|
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
|
||||||
}
|
}
|
||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
|
const orderedFields = useMemo(() => getOrderedFields(contactType, allFieldDefs), [contactType, allFieldDefs]);
|
||||||
const activeCount = filters.conditions.length;
|
const activeCount = filters.conditions.length;
|
||||||
|
|
||||||
const addCondition = () => {
|
const addCondition = () => {
|
||||||
@@ -327,6 +364,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
finance: 'Finanzen',
|
finance: 'Finanzen',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
meta: 'Metadaten',
|
meta: 'Metadaten',
|
||||||
|
custom: 'Benutzerdefinierte Felder',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -361,7 +399,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
style={{
|
style={{
|
||||||
top: panelPos.top,
|
top: panelPos.top,
|
||||||
left: panelPos.left,
|
left: panelPos.left,
|
||||||
width: '480px',
|
width: 'min(480px, 90vw)',
|
||||||
maxHeight: '70vh',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
zIndex: 3000,
|
zIndex: 3000,
|
||||||
@@ -421,7 +459,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
||||||
{filters.conditions.map((cond) => {
|
{filters.conditions.map((cond) => {
|
||||||
const def = FIELD_DEFS.find((f) => f.key === cond.field);
|
const def = allFieldDefs.find((f) => f.key === cond.field);
|
||||||
const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator);
|
const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -475,12 +513,12 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filters.conditions.map((cond, idx) => {
|
{filters.conditions.map((cond, idx) => {
|
||||||
const def = FIELD_DEFS.find((f) => f.key === cond.field);
|
const def = allFieldDefs.find((f) => f.key === cond.field);
|
||||||
const operators = getOperators(def?.type || 'text');
|
const operators = getOperators(def?.type || 'text');
|
||||||
const currentOp = operators.find((o) => o.value === cond.operator) || operators[0];
|
const currentOp = operators.find((o) => o.value === cond.operator) || operators[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cond.id} className="flex items-center gap-1.5">
|
<div key={cond.id} className="flex items-center gap-1.5 flex-wrap">
|
||||||
{/* Logic prefix */}
|
{/* Logic prefix */}
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<span className="text-[10px] font-bold text-primary-600 w-8 text-center">
|
<span className="text-[10px] font-bold text-primary-600 w-8 text-center">
|
||||||
@@ -493,7 +531,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
<select
|
<select
|
||||||
value={cond.field}
|
value={cond.field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newDef = FIELD_DEFS.find((f) => f.key === e.target.value);
|
const newDef = allFieldDefs.find((f) => f.key === e.target.value);
|
||||||
const newOps = getOperators(newDef?.type || 'text');
|
const newOps = getOperators(newDef?.type || 'text');
|
||||||
updateCondition(cond.id, {
|
updateCondition(cond.id, {
|
||||||
field: e.target.value,
|
field: e.target.value,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
|
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||||
|
|
||||||
// ─── Field definitions ────────────────────────────────────────────────────────
|
// ─── Field definitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -15,10 +16,10 @@ interface GroupFieldDef {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta';
|
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_FIELDS: GroupFieldDef[] = [
|
let GROUP_FIELDS: GroupFieldDef[] = [
|
||||||
// General
|
// General
|
||||||
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
|
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
|
||||||
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
||||||
@@ -84,6 +85,10 @@ export const emptyGroupState: GroupState = {
|
|||||||
// ─── Group logic ──────────────────────────────────────────────────────────────
|
// ─── Group logic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getFieldValue(contact: UnifiedContact, field: string): any {
|
function getFieldValue(contact: UnifiedContact, field: string): any {
|
||||||
|
if (field.startsWith('custom.')) {
|
||||||
|
const customField = field.slice(7);
|
||||||
|
return contact.custom?.[customField];
|
||||||
|
}
|
||||||
return (contact as any)[field];
|
return (contact as any)[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +98,8 @@ export interface GroupedContacts {
|
|||||||
contacts: UnifiedContact[];
|
contacts: UnifiedContact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState): GroupedContacts[] {
|
export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState, allDefs?: GroupFieldDef[]): GroupedContacts[] {
|
||||||
|
const defs = allDefs || GROUP_FIELDS;
|
||||||
if (!groupState.conditions.length) {
|
if (!groupState.conditions.length) {
|
||||||
return [{ key: 'all', label: 'Alle', contacts }];
|
return [{ key: 'all', label: 'Alle', contacts }];
|
||||||
}
|
}
|
||||||
@@ -102,7 +108,7 @@ export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState
|
|||||||
let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }];
|
let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }];
|
||||||
|
|
||||||
for (const cond of groupState.conditions) {
|
for (const cond of groupState.conditions) {
|
||||||
const def = GROUP_FIELDS.find((f) => f.key === cond.field);
|
const def = defs.find((f) => f.key === cond.field);
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
|
|
||||||
const newGroups: GroupedContacts[] = [];
|
const newGroups: GroupedContacts[] = [];
|
||||||
@@ -137,17 +143,18 @@ export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState
|
|||||||
|
|
||||||
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
||||||
|
|
||||||
function getOrderedFields(contactType?: 'company' | 'person' | undefined): GroupFieldDef[] {
|
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: GroupFieldDef[]): GroupFieldDef[] {
|
||||||
|
const defs = allDefs || GROUP_FIELDS;
|
||||||
if (contactType === 'company') {
|
if (contactType === 'company') {
|
||||||
const order = ['general', 'company', 'address', 'notes', 'meta', 'person'];
|
const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
|
||||||
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
if (contactType === 'person') {
|
if (contactType === 'person') {
|
||||||
const order = ['general', 'person', 'address', 'notes', 'meta', 'company'];
|
const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
|
||||||
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
const order = ['general', 'company', 'person', 'address', 'notes', 'meta'];
|
const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
|
||||||
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
@@ -169,6 +176,31 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
// Fetch custom field definitions (Feature 4)
|
||||||
|
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
|
||||||
|
|
||||||
|
// Merge custom field definitions into GROUP_FIELDS
|
||||||
|
const allGroupFields = useMemo(() => {
|
||||||
|
const baseFields = GROUP_FIELDS.filter((f) => !f.key.startsWith('custom.'));
|
||||||
|
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
|
||||||
|
const customFields: GroupFieldDef[] = customFieldDefs.items
|
||||||
|
.filter((def) => def.is_active)
|
||||||
|
.map((def) => {
|
||||||
|
const fieldType: FieldType =
|
||||||
|
def.field_type === 'number' ? 'number' :
|
||||||
|
def.field_type === 'date' ? 'date' :
|
||||||
|
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
|
||||||
|
'text';
|
||||||
|
return {
|
||||||
|
key: `custom.${def.name}`,
|
||||||
|
label: def.label || def.name,
|
||||||
|
type: fieldType,
|
||||||
|
group: 'custom' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...baseFields, ...customFields];
|
||||||
|
}, [customFieldDefs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -183,12 +215,14 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (!open && btnRef.current) {
|
if (!open && btnRef.current) {
|
||||||
const rect = btnRef.current.getBoundingClientRect();
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
setPanelPos({ top: rect.bottom + 4, left: rect.left });
|
const panelWidth = Math.min(420, window.innerWidth * 0.9);
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
|
||||||
|
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
|
||||||
}
|
}
|
||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
|
const orderedFields = useMemo(() => getOrderedFields(contactType, allGroupFields), [contactType, allGroupFields]);
|
||||||
const activeCount = groupState.conditions.length;
|
const activeCount = groupState.conditions.length;
|
||||||
|
|
||||||
const addCondition = () => {
|
const addCondition = () => {
|
||||||
@@ -246,6 +280,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
meta: 'Metadaten',
|
meta: 'Metadaten',
|
||||||
|
custom: 'Benutzerdefinierte Felder',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -280,7 +315,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
style={{
|
style={{
|
||||||
top: panelPos.top,
|
top: panelPos.top,
|
||||||
left: panelPos.left,
|
left: panelPos.left,
|
||||||
width: '420px',
|
width: 'min(420px, 90vw)',
|
||||||
maxHeight: '70vh',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
zIndex: 3000,
|
zIndex: 3000,
|
||||||
@@ -320,7 +355,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
||||||
{groupState.conditions.map((cond, idx) => {
|
{groupState.conditions.map((cond, idx) => {
|
||||||
const def = GROUP_FIELDS.find((f) => f.key === cond.field);
|
const def = allGroupFields.find((f) => f.key === cond.field);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={cond.id}
|
key={cond.id}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
|
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
|
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||||
|
|
||||||
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
|
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
|
||||||
|
|
||||||
@@ -15,10 +16,10 @@ interface SortFieldDef {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta';
|
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT_FIELDS: SortFieldDef[] = [
|
let SORT_FIELDS: SortFieldDef[] = [
|
||||||
// General
|
// General
|
||||||
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
||||||
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
|
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
|
||||||
@@ -86,6 +87,10 @@ export const emptySortState: SortState = {
|
|||||||
// ─── Sort logic ───────────────────────────────────────────────────────────────
|
// ─── Sort logic ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getFieldValue(contact: UnifiedContact, field: string): any {
|
function getFieldValue(contact: UnifiedContact, field: string): any {
|
||||||
|
if (field.startsWith('custom.')) {
|
||||||
|
const customField = field.slice(7);
|
||||||
|
return contact.custom?.[customField];
|
||||||
|
}
|
||||||
return (contact as any)[field];
|
return (contact as any)[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +114,14 @@ function compareValues(a: any, b: any, fieldType: FieldType): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySorting(contacts: UnifiedContact[], sortState: SortState): UnifiedContact[] {
|
export function applySorting(contacts: UnifiedContact[], sortState: SortState, allDefs?: SortFieldDef[]): UnifiedContact[] {
|
||||||
if (!sortState.conditions.length) return contacts;
|
if (!sortState.conditions.length) return contacts;
|
||||||
|
|
||||||
|
const defs = allDefs || SORT_FIELDS;
|
||||||
const sorted = [...contacts];
|
const sorted = [...contacts];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
for (const cond of sortState.conditions) {
|
for (const cond of sortState.conditions) {
|
||||||
const def = SORT_FIELDS.find((f) => f.key === cond.field);
|
const def = defs.find((f) => f.key === cond.field);
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type);
|
const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type);
|
||||||
if (cmp !== 0) {
|
if (cmp !== 0) {
|
||||||
@@ -129,17 +135,18 @@ export function applySorting(contacts: UnifiedContact[], sortState: SortState):
|
|||||||
|
|
||||||
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
||||||
|
|
||||||
function getOrderedFields(contactType?: 'company' | 'person' | undefined): SortFieldDef[] {
|
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: SortFieldDef[]): SortFieldDef[] {
|
||||||
|
const defs = allDefs || SORT_FIELDS;
|
||||||
if (contactType === 'company') {
|
if (contactType === 'company') {
|
||||||
const order = ['general', 'company', 'address', 'notes', 'meta', 'person'];
|
const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
|
||||||
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
if (contactType === 'person') {
|
if (contactType === 'person') {
|
||||||
const order = ['general', 'person', 'address', 'notes', 'meta', 'company'];
|
const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
|
||||||
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
const order = ['general', 'company', 'person', 'address', 'notes', 'meta'];
|
const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
|
||||||
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
@@ -161,6 +168,31 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
// Fetch custom field definitions (Feature 4)
|
||||||
|
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
|
||||||
|
|
||||||
|
// Merge custom field definitions into SORT_FIELDS
|
||||||
|
const allSortFields = useMemo(() => {
|
||||||
|
const baseFields = SORT_FIELDS.filter((f) => !f.key.startsWith('custom.'));
|
||||||
|
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
|
||||||
|
const customFields: SortFieldDef[] = customFieldDefs.items
|
||||||
|
.filter((def) => def.is_active)
|
||||||
|
.map((def) => {
|
||||||
|
const fieldType: FieldType =
|
||||||
|
def.field_type === 'number' ? 'number' :
|
||||||
|
def.field_type === 'date' ? 'date' :
|
||||||
|
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
|
||||||
|
'text';
|
||||||
|
return {
|
||||||
|
key: `custom.${def.name}`,
|
||||||
|
label: def.label || def.name,
|
||||||
|
type: fieldType,
|
||||||
|
group: 'custom' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...baseFields, ...customFields];
|
||||||
|
}, [customFieldDefs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -175,12 +207,14 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (!open && btnRef.current) {
|
if (!open && btnRef.current) {
|
||||||
const rect = btnRef.current.getBoundingClientRect();
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
setPanelPos({ top: rect.bottom + 4, left: rect.left });
|
const panelWidth = Math.min(420, window.innerWidth * 0.9);
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
|
||||||
|
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
|
||||||
}
|
}
|
||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
|
const orderedFields = useMemo(() => getOrderedFields(contactType, allSortFields), [contactType, allSortFields]);
|
||||||
const activeCount = sortState.conditions.length;
|
const activeCount = sortState.conditions.length;
|
||||||
|
|
||||||
const addCondition = () => {
|
const addCondition = () => {
|
||||||
@@ -239,6 +273,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
meta: 'Metadaten',
|
meta: 'Metadaten',
|
||||||
|
custom: 'Benutzerdefinierte Felder',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,7 +308,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
style={{
|
style={{
|
||||||
top: panelPos.top,
|
top: panelPos.top,
|
||||||
left: panelPos.left,
|
left: panelPos.left,
|
||||||
width: '420px',
|
width: 'min(420px, 90vw)',
|
||||||
maxHeight: '70vh',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
zIndex: 3000,
|
zIndex: 3000,
|
||||||
@@ -313,7 +348,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
||||||
{sortState.conditions.map((cond, idx) => {
|
{sortState.conditions.map((cond, idx) => {
|
||||||
const def = SORT_FIELDS.find((f) => f.key === cond.field);
|
const def = allSortFields.find((f) => f.key === cond.field);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={cond.id}
|
key={cond.id}
|
||||||
@@ -334,7 +369,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
{/* Sort rows */}
|
{/* Sort rows */}
|
||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{sortState.conditions.map((cond, idx) => {
|
{sortState.conditions.map((cond, idx) => {
|
||||||
const def = SORT_FIELDS.find((f) => f.key === cond.field);
|
const def = allSortFields.find((f) => f.key === cond.field);
|
||||||
return (
|
return (
|
||||||
<div key={cond.id} className="flex items-center gap-1.5">
|
<div key={cond.id} className="flex items-center gap-1.5">
|
||||||
{/* Priority number + reorder */}
|
{/* Priority number + reorder */}
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ import { useSavedFilters as useSavedFiltersApi, useCreateSavedFilter, useDeleteS
|
|||||||
import {
|
import {
|
||||||
useUnifiedContacts,
|
useUnifiedContacts,
|
||||||
useUnifiedContact,
|
useUnifiedContact,
|
||||||
|
useDeleteUnifiedContact,
|
||||||
|
useUpdateUnifiedContact,
|
||||||
type UnifiedContact,
|
type UnifiedContact,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
|
import { useContactFolders } from '@/api/contacts';
|
||||||
|
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -56,8 +60,31 @@ export function ContactsListPage() {
|
|||||||
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
|
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
|
||||||
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
||||||
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
|
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
|
||||||
|
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
|
||||||
const openWindow = useWindowStore((s) => s.openWindow);
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
|
|
||||||
|
// 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.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)
|
// Saved views from API (durable, per-user)
|
||||||
const { data: apiSavedViews } = useSavedViews('contacts');
|
const { data: apiSavedViews } = useSavedViews('contacts');
|
||||||
const createViewMut = useCreateSavedView();
|
const createViewMut = useCreateSavedView();
|
||||||
@@ -144,14 +171,14 @@ export function ContactsListPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Apply FilterPanel conditions
|
// Apply FilterPanel conditions
|
||||||
result = applyFilters(result, filterState);
|
result = applyFilters(result, filterState, customDefsForFunctions);
|
||||||
// Apply SortPanel sorting
|
// Apply SortPanel sorting
|
||||||
result = applySorting(result, sortState);
|
result = applySorting(result, sortState, customDefsForFunctions);
|
||||||
return result;
|
return result;
|
||||||
}, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
|
}, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
|
||||||
|
|
||||||
// Apply GroupPanel grouping
|
// Apply GroupPanel grouping
|
||||||
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]);
|
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState, customDefsForFunctions), [filteredContacts, groupState, customDefsForFunctions]);
|
||||||
|
|
||||||
// Handle folder selection
|
// Handle folder selection
|
||||||
const handleSelectFilter = useCallback((filter: ContactFilter) => {
|
const handleSelectFilter = useCallback((filter: ContactFilter) => {
|
||||||
@@ -221,6 +248,33 @@ export function ContactsListPage() {
|
|||||||
setActiveView('list');
|
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
|
// Save current view configuration as a custom view — opens dialog
|
||||||
const handleSaveView = useCallback(() => {
|
const handleSaveView = useCallback(() => {
|
||||||
setSaveViewDialogOpen(true);
|
setSaveViewDialogOpen(true);
|
||||||
@@ -585,6 +639,11 @@ export function ContactsListPage() {
|
|||||||
onSortStateChange={setSortState}
|
onSortStateChange={setSortState}
|
||||||
groupState={groupState}
|
groupState={groupState}
|
||||||
groupedContacts={groupedContacts}
|
groupedContacts={groupedContacts}
|
||||||
|
selectedContactIds={selectedContactIds}
|
||||||
|
onSelectionChange={setSelectedContactIds}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
|
onBulkAssignFolder={handleBulkAssignFolder}
|
||||||
|
onBulkAddTags={handleBulkAddTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -662,6 +721,11 @@ export function ContactsListPage() {
|
|||||||
onSortStateChange={setSortState}
|
onSortStateChange={setSortState}
|
||||||
groupState={groupState}
|
groupState={groupState}
|
||||||
groupedContacts={groupedContacts}
|
groupedContacts={groupedContacts}
|
||||||
|
selectedContactIds={selectedContactIds}
|
||||||
|
onSelectionChange={setSelectedContactIds}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
|
onBulkAssignFolder={handleBulkAssignFolder}
|
||||||
|
onBulkAddTags={handleBulkAddTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user