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 { EmptyState } from '@/components/ui/EmptyState';
|
||||
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 { 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';
|
||||
|
||||
@@ -315,6 +317,13 @@ export interface ContactListProps {
|
||||
onSortStateChange?: (s: SortState) => void;
|
||||
groupState?: GroupState;
|
||||
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({
|
||||
@@ -334,6 +343,11 @@ export function ContactList({
|
||||
onSortStateChange,
|
||||
groupState,
|
||||
groupedContacts,
|
||||
selectedContactIds,
|
||||
onSelectionChange,
|
||||
onBulkDelete,
|
||||
onBulkAssignFolder,
|
||||
onBulkAddTags,
|
||||
}: ContactListProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -346,6 +360,166 @@ export function ContactList({
|
||||
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | 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
|
||||
const shouldVirtualize = contacts.length >= 50;
|
||||
|
||||
@@ -670,26 +844,48 @@ export function ContactList({
|
||||
const renderContactRow = (contact: UnifiedContact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
draggable
|
||||
draggable={isCustomSortActive}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
if (isCustomSortActive) {
|
||||
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)}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
isCustomSortActive && 'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
{orderedVisibleColumns.map((col) => (
|
||||
<td
|
||||
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) }}
|
||||
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>
|
||||
))}
|
||||
</tr>
|
||||
@@ -753,6 +949,133 @@ export function ContactList({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<table
|
||||
className="text-sm"
|
||||
@@ -784,7 +1107,13 @@ export function ContactList({
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{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')}>
|
||||
@@ -803,13 +1132,49 @@ export function ContactList({
|
||||
</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{isGrouped ? (
|
||||
renderGroupRows(groupTree)
|
||||
) : (
|
||||
contacts.map((contact) => renderContactRow(contact))
|
||||
applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user