From dbf804f0e312762984b73b9ab566b337fc149b43 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 28 Jul 2026 15:21:03 +0200 Subject: [PATCH] feat: table view overhaul - drag resize, drag reorder, multi-sort headers, tree grouping --- .../src/components/contacts/ContactList.tsx | 658 +++++++++++++++--- frontend/src/pages/ContactsList.tsx | 8 + 2 files changed, 575 insertions(+), 91 deletions(-) diff --git a/frontend/src/components/contacts/ContactList.tsx b/frontend/src/components/contacts/ContactList.tsx index bedbc8b..1fdafde 100644 --- a/frontend/src/components/contacts/ContactList.tsx +++ b/frontend/src/components/contacts/ContactList.tsx @@ -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) => ( + + ), + }, + { + 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 ( @@ -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(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); + // 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(); + 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 (
@@ -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 ( + + {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) => ( - e.stopPropagation()}> - - - - {getDisplayName(contact)} - {getEmail(contact)} - {getPhone(contact)} - {getCity(contact)} - -
- {getTags(contact).slice(0, 3).map((tag) => ( - {tag} - ))} -
- + {orderedVisibleColumns.map((col) => ( + e.stopPropagation() : undefined} + > + {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 (
-
- +
+
- - - - - - - + {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 ( + + ); + })} - {shouldVirtualize ? ( - <> - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const contact = contacts[virtualRow.index]; - return ( - - {virtualRow.index === 0 && ( - - - )} - {renderContactRow(contact)} - {virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && ( - - - )} - - ); - })} - + {isGrouped ? ( + renderGroupRows(groupTree) ) : ( contacts.map((contact) => renderContactRow(contact)) )} diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index b63fb2f..583fbed 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -571,6 +571,10 @@ export function ContactsListPage() { sortBy={sortBy} sortOrder={sortOrder} onSortChange={handleSortChange} + sortState={sortState} + onSortStateChange={setSortState} + groupState={groupState} + groupedContacts={groupedContacts} /> @@ -642,6 +646,10 @@ export function ContactsListPage() { sortBy={sortBy} sortOrder={sortOrder} onSortChange={handleSortChange} + sortState={sortState} + onSortStateChange={setSortState} + groupState={groupState} + groupedContacts={groupedContacts} /> )}
- - handleSort('type')} - aria-sort={sortBy === 'type' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'} - > - {t('contacts.type')}{sortIcon('type')} - handleSort('displayname')} - aria-sort={sortBy === 'displayname' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'} - > - {t('contacts.fullName')}{sortIcon('displayname')} - handleSort('email_1')} - aria-sort={sortBy === 'email_1' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'} - > - {t('contacts.email')}{sortIcon('email_1')} - {t('contacts.phone')} handleSort('mailing_city')} - aria-sort={sortBy === 'mailing_city' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'} - > - {t('address.city')}{sortIcon('mailing_city')} - {t('tags.title')} 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' ? ( + + ) : ( + <> + + {col.label} + + {col.sortable && getSortIndicator(col.key)} + + )} +
+ {col.reorderable && ( +
handleResizeStart(e, col.key)} + /> + )} +
-
-