diff --git a/frontend/src/components/contacts/ContactList.tsx b/frontend/src/components/contacts/ContactList.tsx index 4ec9ac6..56cac4f 100644 --- a/frontend/src/components/contacts/ContactList.tsx +++ b/frontend/src/components/contacts/ContactList.tsx @@ -6,9 +6,11 @@ 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 } from 'lucide-react'; +import { Loader2, ChevronRight, ChevronDown, Settings, GripVertical, Info } from 'lucide-react'; export type ContactViewMode = 'list' | 'table' | 'cards'; @@ -315,6 +317,13 @@ export interface ContactListProps { 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({ @@ -334,6 +343,11 @@ export function ContactList({ onSortStateChange, groupState, groupedContacts, + selectedContactIds, + onSelectionChange, + onBulkDelete, + onBulkAssignFolder, + onBulkAddTags, }: ContactListProps) { const { t } = useTranslation(); const scrollRef = useRef(null); @@ -346,6 +360,166 @@ export function ContactList({ 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; @@ -670,26 +844,48 @@ export function ContactList({ const renderContactRow = (contact: UnifiedContact) => ( { - e.dataTransfer.setData('text/plain', contact.id); - e.dataTransfer.effectAllowed = 'move'; + 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() : undefined} + onClick={col.key === 'checkbox' ? (e) => { e.stopPropagation(); toggleContactSelection(contact.id); } : undefined} > - {col.render(contact)} + {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) + )} ))} @@ -753,6 +949,133 @@ export function ContactList({ 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 +
+ )} +
{col.key === 'checkbox' ? ( - + 0 && contacts.every((c) => selectedContactIds.has(c.id)) : false} + onChange={toggleSelectAll} + className="rounded border-secondary-300" + aria-label={t('common.all')} + /> ) : ( <> @@ -803,13 +1132,49 @@ export function ContactList({ ); })} + {/* Column visibility settings button (Feature 1) */} +
{isGrouped ? ( renderGroupRows(groupTree) ) : ( - contacts.map((contact) => renderContactRow(contact)) + applyCustomOrder(contacts).map((contact) => renderContactRow(contact)) )}
+
+ + {colMenuOpen && ( +
+
Spalten verwalten
+ {ALL_COLUMNS.map((col) => ( + + ))} +
+ )} +
+
diff --git a/frontend/src/components/contacts/FilterPanel.tsx b/frontend/src/components/contacts/FilterPanel.tsx index ead1a9d..329d2cf 100644 --- a/frontend/src/components/contacts/FilterPanel.tsx +++ b/frontend/src/components/contacts/FilterPanel.tsx @@ -6,6 +6,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react'; import type { UnifiedContact } from '@/api/unifiedContacts'; +import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions'; // ─── Field definitions ─────────────────────────────────────────────────────── @@ -15,10 +16,10 @@ interface FilterFieldDef { key: string; label: string; type: FieldType; - options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta'; + options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta' | 'custom'; } -const FIELD_DEFS: FilterFieldDef[] = [ +let FIELD_DEFS: FilterFieldDef[] = [ // General { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, { key: 'type', label: 'Typ', type: 'select', group: 'general', options: [ @@ -139,11 +140,16 @@ export const emptyFilterState: FilterState = { // ─── Filter logic ───────────────────────────────────────────────────────────── function getFieldValue(contact: UnifiedContact, field: string): any { + if (field.startsWith('custom.')) { + const customField = field.slice(7); + return contact.custom?.[customField]; + } return (contact as any)[field]; } -function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean { - const def = FIELD_DEFS.find((f) => f.key === cond.field); +function matchesCondition(contact: UnifiedContact, cond: FilterCondition, allDefs?: FilterFieldDef[]): boolean { + const defs = allDefs || FIELD_DEFS; + const def = defs.find((f) => f.key === cond.field); if (!def) return true; const val = getFieldValue(contact, cond.field); const op = cond.operator; @@ -175,38 +181,39 @@ function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boole } } -export function applyFilters(contacts: UnifiedContact[], filters: FilterState): UnifiedContact[] { +export function applyFilters(contacts: UnifiedContact[], filters: FilterState, allDefs?: FilterFieldDef[]): UnifiedContact[] { if (!filters.conditions.length) return contacts; if (filters.logic === 'AND') { - return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond))); + return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond, allDefs))); } else { - return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond))); + return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond, allDefs))); } } // ─── Context-sensitive field ordering ────────────────────────────────────────── -function getOrderedFields(contactType?: 'company' | 'person' | undefined): FilterFieldDef[] { +function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: FilterFieldDef[]): FilterFieldDef[] { + const defs = allDefs || FIELD_DEFS; if (contactType === 'company') { // Company fields first, then general, then person, then rest - const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'person']; - return [...FIELD_DEFS].sort((a, b) => { + const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'custom', 'person']; + return [...defs].sort((a, b) => { const ai = order.indexOf(a.group || 'general'); const bi = order.indexOf(b.group || 'general'); return ai - bi; }); } if (contactType === 'person') { - const order = ['general', 'person', 'address', 'notes', 'meta', 'company', 'finance']; - return [...FIELD_DEFS].sort((a, b) => { + const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company', 'finance']; + return [...defs].sort((a, b) => { const ai = order.indexOf(a.group || 'general'); const bi = order.indexOf(b.group || 'general'); return ai - bi; }); } // All: general first, then alphabetical by group - const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta']; - return [...FIELD_DEFS].sort((a, b) => { + const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta', 'custom']; + return [...defs].sort((a, b) => { const ai = order.indexOf(a.group || 'general'); const bi = order.indexOf(b.group || 'general'); return ai - bi; @@ -242,6 +249,34 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter const panelRef = useRef(null); const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); + // Fetch custom field definitions (Feature 4) + const { data: customFieldDefs } = useCustomFieldDefinitions('contact'); + + // Merge custom field definitions into FIELD_DEFS + const allFieldDefs = useMemo(() => { + const baseFields = FIELD_DEFS.filter((f) => !f.key.startsWith('custom.')); + if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields; + const customFields: FilterFieldDef[] = customFieldDefs.items + .filter((def) => def.is_active) + .map((def) => { + const fieldType: FieldType = + def.field_type === 'number' ? 'number' : + def.field_type === 'date' ? 'date' : + def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' : + 'text'; + return { + key: `custom.${def.name}`, + label: def.label || def.name, + type: fieldType, + group: 'custom' as const, + options: (def.field_type === 'select' || def.field_type === 'multiselect') && def.options + ? def.options.map((opt) => ({ value: opt, label: opt })) + : undefined, + }; + }); + return [...baseFields, ...customFields]; + }, [customFieldDefs]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -256,12 +291,14 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter const handleToggle = () => { if (!open && btnRef.current) { const rect = btnRef.current.getBoundingClientRect(); - setPanelPos({ top: rect.bottom + 4, left: rect.left }); + const panelWidth = Math.min(480, window.innerWidth * 0.9); + const left = Math.min(rect.left, window.innerWidth - panelWidth - 10); + setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) }); } setOpen(!open); }; - const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]); + const orderedFields = useMemo(() => getOrderedFields(contactType, allFieldDefs), [contactType, allFieldDefs]); const activeCount = filters.conditions.length; const addCondition = () => { @@ -327,6 +364,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter finance: 'Finanzen', notes: 'Notizen', meta: 'Metadaten', + custom: 'Benutzerdefinierte Felder', }; return ( @@ -361,7 +399,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter style={{ top: panelPos.top, left: panelPos.left, - width: '480px', + width: 'min(480px, 90vw)', maxHeight: '70vh', overflowY: 'auto', zIndex: 3000, @@ -421,7 +459,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter {activeCount > 0 && (
{filters.conditions.map((cond) => { - const def = FIELD_DEFS.find((f) => f.key === cond.field); + const def = allFieldDefs.find((f) => f.key === cond.field); const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator); return ( )} {filters.conditions.map((cond, idx) => { - const def = FIELD_DEFS.find((f) => f.key === cond.field); + const def = allFieldDefs.find((f) => f.key === cond.field); const operators = getOperators(def?.type || 'text'); const currentOp = operators.find((o) => o.value === cond.operator) || operators[0]; return ( -
+
{/* Logic prefix */} {idx > 0 && ( @@ -493,7 +531,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter