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 (
{t('common.loading')}
);
}
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 ? (
) : (
{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 (
| 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)}
/>
)}
|
);
})}
{/* Column visibility settings button (Feature 1) */}
{colMenuOpen && (
Spalten verwalten
{ALL_COLUMNS.map((col) => (
))}
)}
|
{isGrouped ? (
renderGroupRows(groupTree)
) : (
applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
)}
);
}
// ── 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))}
)}
);
}