1312 lines
48 KiB
TypeScript
1312 lines
48 KiB
TypeScript
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) => (
|
|
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(c)} />
|
|
),
|
|
},
|
|
{
|
|
key: 'type',
|
|
label: 'Typ',
|
|
width: 80,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <TypeBadge type={c.type} />,
|
|
},
|
|
{
|
|
key: 'displayname',
|
|
label: 'Anzeigename',
|
|
width: 200,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="font-medium text-secondary-900">{getDisplayName(c)}</span>,
|
|
},
|
|
{
|
|
key: 'email_1',
|
|
label: 'E-Mail',
|
|
width: 200,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{getEmail(c)}</span>,
|
|
},
|
|
{
|
|
key: 'phone_1',
|
|
label: 'Telefon',
|
|
width: 150,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{getPhone(c)}</span>,
|
|
},
|
|
{
|
|
key: 'mailing_city',
|
|
label: 'Stadt',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{getCity(c)}</span>,
|
|
},
|
|
{
|
|
key: 'tags',
|
|
label: 'Tags',
|
|
width: 150,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => (
|
|
<div className="flex flex-wrap gap-1">
|
|
{getTags(c).slice(0, 3).map((tag) => (
|
|
<Badge key={tag} variant="secondary">{tag}</Badge>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'code',
|
|
label: 'Kunden-Nr.',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.code || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'vat_code',
|
|
label: 'USt-IdNr.',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.vat_code || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'firstname',
|
|
label: 'Vorname',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.firstname || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'surname',
|
|
label: 'Nachname',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.surname || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'mailing_postalcode',
|
|
label: 'PLZ',
|
|
width: 80,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.mailing_postalcode || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'mailing_country',
|
|
label: 'Land',
|
|
width: 100,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.mailing_country || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'website',
|
|
label: 'Website',
|
|
width: 150,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{c.website || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
label: 'Erstellt am',
|
|
width: 120,
|
|
sortable: true,
|
|
reorderable: true,
|
|
render: (c) => <span className="text-secondary-600">{formatDate(c.created_at)}</span>,
|
|
},
|
|
];
|
|
|
|
// 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<string, number> {
|
|
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 (
|
|
<Badge variant={type === 'company' ? 'primary' : 'info'}>
|
|
{type === 'company' ? t('contacts.companies') : t('contacts.persons')}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
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<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({
|
|
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<HTMLDivElement>(null);
|
|
|
|
// ── Table view state (hooks must be before early returns) ──
|
|
const [columnOrder, setColumnOrder] = useState<string[]>(loadColumnOrder);
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(loadColumnWidths);
|
|
const [visibleColumns, setVisibleColumns] = useState<string[]>(loadVisibleColumns);
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
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;
|
|
|
|
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<string>();
|
|
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 (
|
|
<div className="flex items-center justify-center py-12" data-testid="contact-list-loading">
|
|
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
|
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (contacts.length === 0) {
|
|
return (
|
|
<div data-testid="contact-list-empty">
|
|
<EmptyState title={t('contacts.emptyTitle')} description={t('contacts.emptyDescription')} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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) => (
|
|
<li key={contact.id}>
|
|
<button
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.dataTransfer.setData('text/plain', contact.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onClick={() => onSelectContact(contact)}
|
|
className={clsx(
|
|
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
|
selectedContactId === contact.id
|
|
? 'bg-primary-50'
|
|
: 'hover:bg-secondary-50',
|
|
)}
|
|
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
|
>
|
|
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
|
{getInitials(contact)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
|
<TypeBadge type={contact.type} />
|
|
</div>
|
|
<div className="text-xs text-secondary-500 truncate">
|
|
{getEmail(contact)} · {getCity(contact)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
|
|
// Group header renderer for list view
|
|
const renderListGroupHeader = (group: GroupedContacts) => {
|
|
const isExpanded = expandedGroups.has(group.key);
|
|
return (
|
|
<div
|
|
key={`group-header-${group.key}`}
|
|
className="flex items-center gap-2 px-3 py-2 bg-secondary-50 border-b border-secondary-200"
|
|
>
|
|
<button
|
|
onClick={() => toggleGroup(group.key)}
|
|
className="flex items-center justify-center w-5 h-5 hover:bg-secondary-200 rounded transition-colors flex-shrink-0"
|
|
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
<span className="font-medium text-sm text-secondary-700 truncate">{group.label}</span>
|
|
<span className="text-secondary-400 text-xs flex-shrink-0">({group.contacts.length})</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full" data-testid="contact-list-view">
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
{isGrouped && groupedContacts ? (
|
|
<div>
|
|
{groupedContacts.map((group) => (
|
|
<div key={group.key}>
|
|
{renderListGroupHeader(group)}
|
|
{expandedGroups.has(group.key) && (
|
|
<ul className="divide-y divide-secondary-100" role="list">
|
|
{group.contacts.map((contact) => renderContactItem(contact))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : shouldVirtualize ? (
|
|
<ul className="divide-y divide-secondary-100" role="list" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
const contact = contacts[virtualRow.index];
|
|
return (
|
|
<div
|
|
key={contact.id}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{renderContactItem(contact)}
|
|
</div>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<ul className="divide-y divide-secondary-100" role="list">
|
|
{contacts.map((contact) => renderContactItem(contact))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onPageChange={onPageChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<span className="text-primary-600 ml-1 text-xs">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
);
|
|
}
|
|
const idx = sortState.conditions.findIndex((c) => c.field === field);
|
|
if (idx === -1) return null;
|
|
const cond = sortState.conditions[idx];
|
|
return (
|
|
<span className="text-primary-600 ml-1 text-xs whitespace-nowrap">
|
|
{cond.order === 'asc' ? '▲' : '▼'}
|
|
{sortState.conditions.length > 1 ? idx + 1 : ''}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// 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) => (
|
|
<tr
|
|
key={contact.id}
|
|
draggable={isCustomSortActive}
|
|
onDragStart={(e) => {
|
|
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={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(); toggleContactSelection(contact.id); } : undefined}
|
|
>
|
|
{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>
|
|
);
|
|
|
|
// 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(
|
|
<tr
|
|
key={`group-${node.key}`}
|
|
className="bg-secondary-50 border-b border-secondary-200"
|
|
>
|
|
<td
|
|
colSpan={orderedVisibleColumns.length}
|
|
className="px-3 py-2"
|
|
>
|
|
<div
|
|
className="flex items-center gap-2 font-medium text-secondary-700"
|
|
style={{ paddingLeft: `${node.depth * 20}px` }}
|
|
>
|
|
<button
|
|
onClick={() => toggleGroup(node.key)}
|
|
className="flex items-center justify-center w-5 h-5 hover:bg-secondary-200 rounded transition-colors flex-shrink-0"
|
|
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
<span className="truncate">{node.label}</span>
|
|
<span className="text-secondary-400 text-xs flex-shrink-0">({count})</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
|
|
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 (
|
|
<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"
|
|
style={{
|
|
tableLayout: 'fixed',
|
|
width: 'max-content',
|
|
minWidth: '100%',
|
|
}}
|
|
>
|
|
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
|
<tr>
|
|
{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 (
|
|
<th
|
|
key={col.key}
|
|
className="relative px-3 py-2 text-left font-medium text-secondary-600 select-none"
|
|
style={{ width: getColWidth(col.key), minWidth: getColWidth(col.key) }}
|
|
draggable={col.reorderable}
|
|
onDragStart={col.reorderable ? (e) => 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'}
|
|
>
|
|
<div className="flex items-center">
|
|
{col.key === 'checkbox' ? (
|
|
<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')}>
|
|
{col.label}
|
|
</span>
|
|
{col.sortable && getSortIndicator(col.key)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{col.reorderable && (
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary-300 transition-colors"
|
|
onMouseDown={(e) => handleResizeStart(e, col.key)}
|
|
/>
|
|
)}
|
|
</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)
|
|
) : (
|
|
applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onPageChange={onPageChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Cards View ──
|
|
const renderContactCard = (contact: UnifiedContact) => (
|
|
<div
|
|
key={contact.id}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
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}
|
|
>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
|
{getInitials(contact)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
|
</div>
|
|
<TypeBadge type={contact.type} />
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-secondary-500 space-y-0.5">
|
|
<div className="truncate">{getEmail(contact)}</div>
|
|
<div className="truncate">{getPhone(contact)}</div>
|
|
<div className="truncate">{getCity(contact)}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Group header renderer for cards view
|
|
const renderCardsGroupHeader = (group: GroupedContacts) => {
|
|
const isExpanded = expandedGroups.has(group.key);
|
|
return (
|
|
<div
|
|
key={`group-header-${group.key}`}
|
|
className="flex items-center gap-2 px-3 py-2 bg-secondary-50 border-b border-secondary-200 mb-3 sticky top-0 z-10"
|
|
>
|
|
<button
|
|
onClick={() => toggleGroup(group.key)}
|
|
className="flex items-center justify-center w-5 h-5 hover:bg-secondary-200 rounded transition-colors flex-shrink-0"
|
|
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
<span className="font-medium text-sm text-secondary-700 truncate">{group.label}</span>
|
|
<span className="text-secondary-400 text-xs flex-shrink-0">({group.contacts.length})</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div data-testid="contact-cards-view" className="flex flex-col h-full">
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
|
|
{isGrouped && groupedContacts ? (
|
|
<div className="space-y-4">
|
|
{groupedContacts.map((group) => (
|
|
<div key={group.key}>
|
|
{renderCardsGroupHeader(group)}
|
|
{expandedGroups.has(group.key) && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{group.contacts.map((contact) => renderContactCard(contact))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : shouldVirtualize ? (
|
|
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
const contact = contacts[virtualRow.index];
|
|
return (
|
|
<div
|
|
key={contact.id}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{renderContactCard(contact)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{contacts.map((contact) => renderContactCard(contact))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onPageChange={onPageChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|