import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'; // TODO: P2-F18 — Replace hardcoded ALL_COLUMNS with backend/manifest config import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; 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, Settings, GripVertical, Info } from 'lucide-react'; export type ContactViewMode = 'list' | 'table' | 'cards'; // ─── Column Definitions ────────────────────────────────────────────────────── interface ColumnDef { key: string; label: string; width: number; sortable: boolean; reorderable: boolean; render: (contact: UnifiedContact) => React.ReactNode; } const ALL_COLUMNS: ColumnDef[] = [ { key: 'checkbox', label: '', width: 40, sortable: false, reorderable: false, render: (c) => ( ), }, { key: 'type', label: 'Typ', width: 80, sortable: true, reorderable: true, render: (c) => , }, { key: 'displayname', label: 'Anzeigename', width: 200, sortable: true, reorderable: true, render: (c) => {getDisplayName(c)}, }, { key: 'email_1', label: 'E-Mail', width: 200, sortable: true, reorderable: true, render: (c) => {getEmail(c)}, }, { key: 'phone_1', label: 'Telefon', width: 150, sortable: true, reorderable: true, render: (c) => {getPhone(c)}, }, { key: 'mailing_city', label: 'Stadt', width: 120, sortable: true, reorderable: true, render: (c) => {getCity(c)}, }, { key: 'tags', label: 'Tags', width: 150, sortable: true, reorderable: true, render: (c) => (
{getTags(c).slice(0, 3).map((tag) => ( {tag} ))}
), }, { key: 'code', label: 'Kunden-Nr.', width: 120, sortable: true, reorderable: true, render: (c) => {c.code || '—'}, }, { key: 'vat_code', label: 'USt-IdNr.', width: 120, sortable: true, reorderable: true, render: (c) => {c.vat_code || '—'}, }, { key: 'firstname', label: 'Vorname', width: 120, sortable: true, reorderable: true, render: (c) => {c.firstname || '—'}, }, { key: 'surname', label: 'Nachname', width: 120, sortable: true, reorderable: true, render: (c) => {c.surname || '—'}, }, { key: 'mailing_postalcode', label: 'PLZ', width: 80, sortable: true, reorderable: true, render: (c) => {c.mailing_postalcode || '—'}, }, { key: 'mailing_country', label: 'Land', width: 100, sortable: true, reorderable: true, render: (c) => {c.mailing_country || '—'}, }, { key: 'website', label: 'Website', width: 150, sortable: true, reorderable: true, render: (c) => {c.website || '—'}, }, { key: 'created_at', label: 'Erstellt am', width: 120, sortable: true, reorderable: true, render: (c) => {formatDate(c.created_at)}, }, ]; // Default visible column keys (in order) const DEFAULT_VISIBLE_KEYS = ['checkbox', 'type', 'displayname', 'email_1', 'phone_1', 'mailing_city', 'tags']; // ─── localStorage helpers ──────────────────────────────────────────────────── const LS_WIDTHS_KEY = 'contact-table-column-widths'; const LS_ORDER_KEY = 'contact-table-column-order'; const LS_VISIBLE_KEY = 'contact-table-visible-columns'; function loadColumnWidths(): Record { try { const saved = localStorage.getItem(LS_WIDTHS_KEY); if (saved) return JSON.parse(saved); } catch { /* ignore */ } return {}; } function loadColumnOrder(): string[] { try { const saved = localStorage.getItem(LS_ORDER_KEY); if (saved) { const order: string[] = JSON.parse(saved); const allKeys = ALL_COLUMNS.map((c) => c.key); const valid = order.filter((k) => allKeys.includes(k)); const missing = allKeys.filter((k) => !valid.includes(k)); return [...valid, ...missing]; } } catch { /* ignore */ } return ALL_COLUMNS.map((c) => c.key); } function loadVisibleColumns(): string[] { try { const saved = localStorage.getItem(LS_VISIBLE_KEY); if (saved) { const visible: string[] = JSON.parse(saved); const allKeys = ALL_COLUMNS.map((c) => c.key); const valid = visible.filter((k) => allKeys.includes(k)); // Always include checkbox if (!valid.includes('checkbox')) valid.unshift('checkbox'); return valid; } } catch { /* ignore */ } return DEFAULT_VISIBLE_KEYS; } // ─── Helper functions ──────────────────────────────────────────────────────── function TypeBadge({ type }: { type: string }) { const { t } = useTranslation(); return ( {type === 'company' ? t('contacts.companies') : t('contacts.persons')} ); } function getDisplayName(c: UnifiedContact): string { return c.displayname || c.name || [c.firstname, c.surname].filter(Boolean).join(' ') || c.email_1 || c.id; } function getInitials(c: UnifiedContact): string { const name = getDisplayName(c); return name.charAt(0).toUpperCase(); } function getCity(c: UnifiedContact): string { return c.mailing_city || c.visit_city || c.invoice_city || '—'; } function getEmail(c: UnifiedContact): string { return c.email_1 || c.email_2 || '—'; } function getPhone(c: UnifiedContact): string { return c.phone_1 || c.phone_2 || '—'; } function getTags(c: UnifiedContact): string[] { if (!c.tags) return []; return c.tags.split(',').map((t) => t.trim()).filter(Boolean); } function formatDate(dateStr: string | undefined | null): string { if (!dateStr) return '—'; const date = new Date(dateStr); if (isNaN(date.getTime())) return '—'; return date.toLocaleDateString('de-DE'); } // ─── Group tree ────────────────────────────────────────────────────────────── interface GroupTreeNode { key: string; label: string; contacts: UnifiedContact[]; children: GroupTreeNode[]; depth: number; } function buildGroupTree(groupedContacts: GroupedContacts[]): GroupTreeNode[] { const root: GroupTreeNode = { key: 'root', label: '', contacts: [], children: [], depth: -1 }; for (const group of groupedContacts) { const labelParts = group.label.split(' > '); const keyParts = group.key.split('::').slice(1); // Remove 'all' let current = root; for (let i = 0; i < labelParts.length; i++) { const label = labelParts[i]; const keyPart = keyParts[i] || label; const fullKey = current.key === 'root' ? keyPart : `${current.key}::${keyPart}`; let child = current.children.find((c) => c.key === fullKey); if (!child) { child = { key: fullKey, label, contacts: [], children: [], depth: i, }; current.children.push(child); } if (i === labelParts.length - 1) { child.contacts = group.contacts; } current = child; } } return root.children; } function countContactsInNode(node: GroupTreeNode): number { if (node.contacts.length > 0) return node.contacts.length; return node.children.reduce((sum, child) => sum + countContactsInNode(child), 0); } // ─── Component ─────────────────────────────────────────────────────────────── export interface ContactListProps { contacts: UnifiedContact[]; selectedContactId: string | null; onSelectContact: (contact: UnifiedContact) => void; loading?: boolean; currentPage: number; total: number; pageSize: number; onPageChange: (page: number) => void; viewMode: ContactViewMode; sortBy: string; sortOrder: 'asc' | 'desc'; onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void; // New props for multi-sort and grouping sortState?: SortState; onSortStateChange?: (s: SortState) => void; groupState?: GroupState; groupedContacts?: GroupedContacts[]; // Bulk selection props selectedContactIds?: Set; onSelectionChange?: (ids: Set) => void; // Bulk action callbacks onBulkDelete?: (ids: string[]) => void; onBulkAssignFolder?: (ids: string[], folderId: string) => void; onBulkAddTags?: (ids: string[], tags: string[]) => void; } export function ContactList({ contacts, selectedContactId, onSelectContact, loading, currentPage, total, pageSize, onPageChange, viewMode, sortBy, sortOrder, onSortChange, sortState, onSortStateChange, groupState, groupedContacts, selectedContactIds, onSelectionChange, onBulkDelete, onBulkAssignFolder, onBulkAddTags, }: ContactListProps) { const { t } = useTranslation(); const scrollRef = useRef(null); // ── Table view state (hooks must be before early returns) ── const [columnOrder, setColumnOrder] = useState(loadColumnOrder); const [columnWidths, setColumnWidths] = useState>(loadColumnWidths); const [visibleColumns, setVisibleColumns] = useState(loadVisibleColumns); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null); const dragColRef = useRef(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(null); // ── Custom sort drag-and-drop state (Feature 3) ── const LS_CUSTOM_ORDER_KEY = 'contact-custom-order'; const [customOrder, setCustomOrder] = useState(() => { try { const saved = localStorage.getItem(LS_CUSTOM_ORDER_KEY); if (saved) return JSON.parse(saved); } catch { /* ignore */ } return []; }); const dragRowRef = useRef(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(null); const bulkTagsRef = useRef(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()); 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(); 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()); }, [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(); 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; const rowVirtualizer = useVirtualizer({ count: shouldVirtualize ? contacts.length : 0, getScrollElement: () => scrollRef.current, estimateSize: () => (viewMode === 'cards' ? 120 : 56), overscan: 8, enabled: shouldVirtualize, }); // ── Table view derived data ── const orderedVisibleColumns = useMemo(() => { return columnOrder .filter((key) => visibleColumns.includes(key)) .map((key) => ALL_COLUMNS.find((c) => c.key === key)) .filter((c): c is ColumnDef => c !== undefined); }, [columnOrder, visibleColumns]); const isGrouped = !!(groupState && groupState.conditions.length > 0 && groupedContacts); const groupTree = useMemo(() => { if (!isGrouped || !groupedContacts) return []; return buildGroupTree(groupedContacts); }, [isGrouped, groupedContacts]); // Auto-expand new groups, preserve existing expansion state useEffect(() => { if (isGrouped && groupTree.length > 0) { const allKeys = new Set(); const collectKeys = (nodes: GroupTreeNode[]) => { for (const node of nodes) { allKeys.add(node.key); if (node.children.length > 0) collectKeys(node.children); } }; collectKeys(groupTree); setExpandedGroups((prev) => { const next = new Set(prev); for (const key of allKeys) { if (!prev.has(key)) next.add(key); } for (const key of prev) { if (!allKeys.has(key)) next.delete(key); } return next; }); } else if (!isGrouped) { setExpandedGroups(new Set()); } }, [groupTree, isGrouped]); // ── Table view helpers ── const getColWidth = (key: string): number => { return columnWidths[key] ?? ALL_COLUMNS.find((c) => c.key === key)?.width ?? 120; }; const toggleGroup = useCallback((key: string) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); // ── Early returns ── if (loading) { return (
); } if (contacts.length === 0) { return (
); } const totalPages = Math.ceil(total / pageSize); const handleSort = (field: string) => { if (sortBy === field) { onSortChange(field, sortOrder === 'asc' ? 'desc' : 'asc'); } else { onSortChange(field, 'asc'); } }; // ── List View ── if (viewMode === 'list') { const renderContactItem = (contact: UnifiedContact) => (
  • ); // Group header renderer for list view const renderListGroupHeader = (group: GroupedContacts) => { const isExpanded = expandedGroups.has(group.key); return (
    {group.label} ({group.contacts.length})
    ); }; return (
    {isGrouped && groupedContacts ? (
    {groupedContacts.map((group) => (
    {renderListGroupHeader(group)} {expandedGroups.has(group.key) && (
      {group.contacts.map((contact) => renderContactItem(contact))}
    )}
    ))}
    ) : shouldVirtualize ? (
      {rowVirtualizer.getVirtualItems().map((virtualRow) => { const contact = contacts[virtualRow.index]; return (
      {renderContactItem(contact)}
      ); })}
    ) : (
      {contacts.map((contact) => renderContactItem(contact))}
    )}
    ); } // ── Table View ── if (viewMode === 'table') { // Multi-sort header click handler const handleHeaderSort = (field: string) => { if (!onSortStateChange || !sortState) { handleSort(field); return; } const existing = sortState.conditions.find((c) => c.field === field); let newConditions: SortCondition[]; if (!existing) { // Add new sort condition (ascending) newConditions = [ ...sortState.conditions, { id: `sort-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, field, order: 'asc' as const }, ]; } else if (existing.order === 'asc') { // Toggle to descending newConditions = sortState.conditions.map((c) => c.field === field ? { ...c, order: 'desc' as const } : c, ); } else { // Remove sort condition newConditions = sortState.conditions.filter((c) => c.field !== field); } onSortStateChange({ conditions: newConditions }); }; // Sort indicator with priority number const getSortIndicator = (field: string): React.ReactNode => { if (!sortState || sortState.conditions.length === 0) { // Fallback to old single-sort behavior if (sortBy !== field) return null; return ( {sortOrder === 'asc' ? '▲' : '▼'} ); } const idx = sortState.conditions.findIndex((c) => c.field === field); if (idx === -1) return null; const cond = sortState.conditions[idx]; return ( {cond.order === 'asc' ? '▲' : '▼'} {sortState.conditions.length > 1 ? idx + 1 : ''} ); }; // Column resize via mouse drag on header right edge const handleResizeStart = (e: React.MouseEvent, colKey: string) => { e.preventDefault(); e.stopPropagation(); const currentWidth = getColWidth(colKey); resizeRef.current = { key: colKey, startX: e.clientX, startWidth: currentWidth }; const handleMouseMove = (ev: MouseEvent) => { if (!resizeRef.current) return; const delta = ev.clientX - resizeRef.current.startX; const newWidth = Math.max(40, resizeRef.current.startWidth + delta); setColumnWidths((prev) => ({ ...prev, [resizeRef.current!.key]: newWidth })); }; const handleMouseUp = () => { if (resizeRef.current) { setColumnWidths((prev) => { localStorage.setItem(LS_WIDTHS_KEY, JSON.stringify(prev)); return prev; }); } resizeRef.current = null; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Column reorder via HTML5 drag-and-drop const handleColDragStart = (e: React.DragEvent, colKey: string) => { dragColRef.current = colKey; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', colKey); }; const handleColDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleColDrop = (e: React.DragEvent, targetKey: string) => { e.preventDefault(); const sourceKey = dragColRef.current; if (!sourceKey || sourceKey === targetKey) return; setColumnOrder((prev) => { const newOrder = [...prev]; const sourceIdx = newOrder.indexOf(sourceKey); const targetIdx = newOrder.indexOf(targetKey); newOrder.splice(sourceIdx, 1); newOrder.splice(targetIdx, 0, sourceKey); localStorage.setItem(LS_ORDER_KEY, JSON.stringify(newOrder)); return newOrder; }); dragColRef.current = null; }; // Render a single contact row using column definitions const renderContactRow = (contact: UnifiedContact) => ( { 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) => ( { e.stopPropagation(); toggleContactSelection(contact.id); } : undefined} > {col.key === 'checkbox' ? ( {}} className="rounded border-secondary-300" aria-label={getDisplayName(contact)} /> ) : isCustomSortActive && col.key === orderedVisibleColumns.find((c) => c.key !== 'checkbox')?.key ? (
    {col.render(contact)}
    ) : ( col.render(contact) )} ))} ); // Render grouped tree rows recursively const renderGroupRows = (nodes: GroupTreeNode[]): React.ReactNode[] => { const rows: React.ReactNode[] = []; for (const node of nodes) { const isExpanded = expandedGroups.has(node.key); const count = countContactsInNode(node); const hasChildren = node.children.length > 0; rows.push(
    {node.label} ({count})
    ); if (isExpanded) { if (hasChildren) { rows.push(...renderGroupRows(node.children)); } for (const contact of node.contacts) { rows.push(renderContactRow(contact)); } } } return rows; }; // Compute total table width for horizontal scroll const tableMinWidth = orderedVisibleColumns.reduce((sum, col) => sum + getColWidth(col.key), 0); return (
    {/* Bulk action bar (Feature 2) */} {selectedContactIds && selectedContactIds.size > 0 && (
    {selectedContactIds.size} ausgewählt
    {onBulkDelete && ( )} {onBulkAssignFolder && (
    {bulkFolderOpen && (
    {folderList.map((folder) => ( ))} {folderList.length === 0 && (
    Keine Ordner
    )}
    )}
    )} {onBulkAddTags && (
    {bulkTagsOpen && (
    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" />
    )}
    )}
    )} {/* Bulk delete confirmation dialog (Feature 2) */} {bulkDeleteConfirm && (

    Löschen bestätigen

    {selectedContactIds?.size || 0} Kontakt(e) wirklich löschen?

    )} {/* Custom sort info indicator (Feature 3) */} {isCustomSortActive && customOrder.length > 0 && (
    Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren
    )}
    {orderedVisibleColumns.map((col) => { const sortCond = sortState?.conditions.find((c) => c.field === col.key); const ariaSort = sortCond ? (sortCond.order === 'asc' ? 'ascending' : 'descending') : (sortBy === col.key ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'); return ( ); })} {/* Column visibility settings button (Feature 1) */} {isGrouped ? ( renderGroupRows(groupTree) ) : ( applyCustomOrder(contacts).map((contact) => renderContactRow(contact)) )}
    handleColDragStart(e, col.key) : undefined} onDragOver={col.reorderable ? handleColDragOver : undefined} onDrop={col.reorderable ? (e) => handleColDrop(e, col.key) : undefined} onClick={col.sortable ? () => handleHeaderSort(col.key) : undefined} aria-sort={ariaSort as 'ascending' | 'descending' | 'none'} >
    {col.key === 'checkbox' ? ( 0 && contacts.every((c) => selectedContactIds.has(c.id)) : false} onChange={toggleSelectAll} className="rounded border-secondary-300" aria-label={t('common.all')} /> ) : ( <> {col.label} {col.sortable && getSortIndicator(col.key)} )}
    {col.reorderable && (
    handleResizeStart(e, col.key)} /> )}
    {colMenuOpen && (
    Spalten verwalten
    {ALL_COLUMNS.map((col) => ( ))}
    )}
    ); } // ── Cards View ── const renderContactCard = (contact: UnifiedContact) => (
    { e.dataTransfer.setData('text/plain', contact.id); e.dataTransfer.effectAllowed = 'move'; }} onClick={() => onSelectContact(contact)} className={clsx( 'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch', selectedContactId === contact.id ? 'border-primary-300 bg-primary-50' : 'border-secondary-200 bg-white hover:bg-secondary-50', )} role="button" tabIndex={0} aria-current={selectedContactId === contact.id ? 'true' : undefined} >
    {getInitials(contact)}
    {getDisplayName(contact)}
    {getEmail(contact)}
    {getPhone(contact)}
    {getCity(contact)}
    ); // Group header renderer for cards view const renderCardsGroupHeader = (group: GroupedContacts) => { const isExpanded = expandedGroups.has(group.key); return (
    {group.label} ({group.contacts.length})
    ); }; return (
    {isGrouped && groupedContacts ? (
    {groupedContacts.map((group) => (
    {renderCardsGroupHeader(group)} {expandedGroups.has(group.key) && (
    {group.contacts.map((contact) => renderContactCard(contact))}
    )}
    ))}
    ) : shouldVirtualize ? (
    {rowVirtualizer.getVirtualItems().map((virtualRow) => { const contact = contacts[virtualRow.index]; return (
    {renderContactCard(contact)}
    ); })}
    ) : (
    {contacts.map((contact) => renderContactCard(contact))}
    )}
    ); }