feat: table view overhaul - drag resize, drag reorder, multi-sort headers, tree grouping
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
@@ -6,26 +6,202 @@ 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 { Loader2 } from 'lucide-react';
|
||||
import type { SortState, SortCondition } from '@/components/contacts/SortPanel';
|
||||
import type { GroupState, GroupedContacts } from '@/components/contacts/GroupPanel';
|
||||
import { Loader2, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
export type ContactViewMode = 'list' | 'table' | 'cards';
|
||||
|
||||
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;
|
||||
// ─── 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 (
|
||||
@@ -61,6 +237,86 @@ function getTags(c: UnifiedContact): string[] {
|
||||
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[];
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
contacts,
|
||||
selectedContactId,
|
||||
@@ -74,21 +330,89 @@ export function ContactList({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
sortState,
|
||||
onSortStateChange,
|
||||
groupState,
|
||||
groupedContacts,
|
||||
}: 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);
|
||||
|
||||
// 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,
|
||||
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">
|
||||
@@ -193,11 +517,118 @@ export function ContactList({
|
||||
|
||||
// ── Table View ──
|
||||
if (viewMode === 'table') {
|
||||
const sortIcon = (field: string) => {
|
||||
if (sortBy !== field) return null;
|
||||
return sortOrder === 'asc' ? ' ▲' : ' ▼';
|
||||
// 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}
|
||||
@@ -213,87 +644,132 @@ export function ContactList({
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
||||
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getTags(contact).slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
{orderedVisibleColumns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-3 py-2 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
style={{ width: getColWidth(col.key), maxWidth: getColWidth(col.key) }}
|
||||
onClick={col.key === 'checkbox' ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{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">
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto" style={shouldVirtualize ? { maxHeight: '70vh' } : undefined}>
|
||||
<table className="w-full text-sm">
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto" style={{ maxHeight: '70vh' }}>
|
||||
<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>
|
||||
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
||||
onClick={() => handleSort('type')}
|
||||
aria-sort={sortBy === 'type' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
{t('contacts.type')}{sortIcon('type')}
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
||||
onClick={() => handleSort('displayname')}
|
||||
aria-sort={sortBy === 'displayname' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
{t('contacts.fullName')}{sortIcon('displayname')}
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
||||
onClick={() => handleSort('email_1')}
|
||||
aria-sort={sortBy === 'email_1' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
{t('contacts.email')}{sortIcon('email_1')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('contacts.phone')}</th>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
||||
onClick={() => handleSort('mailing_city')}
|
||||
aria-sort={sortBy === 'mailing_city' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
{t('address.city')}{sortIcon('mailing_city')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('tags.title')}</th>
|
||||
{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" 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>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{shouldVirtualize ? (
|
||||
<>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={contact.id}>
|
||||
{virtualRow.index === 0 && (
|
||||
<tr style={{ height: virtualRow.start }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{renderContactRow(contact)}
|
||||
{virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && (
|
||||
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
{isGrouped ? (
|
||||
renderGroupRows(groupTree)
|
||||
) : (
|
||||
contacts.map((contact) => renderContactRow(contact))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user