diff --git a/frontend/src/components/contacts/SortPanel.tsx b/frontend/src/components/contacts/SortPanel.tsx new file mode 100644 index 0000000..44636d4 --- /dev/null +++ b/frontend/src/components/contacts/SortPanel.tsx @@ -0,0 +1,428 @@ +/** + * SmartSuite-style Sort Panel for contacts. + * Multi-field sorting with priority ordering. + */ + +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react'; +import type { UnifiedContact } from '@/api/unifiedContacts'; + +// ─── Field definitions (reuse from FilterPanel) ──────────────────────────────── + +type FieldType = 'text' | 'number' | 'select' | 'date'; + +interface SortFieldDef { + key: string; + label: string; + type: FieldType; + group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta'; +} + +const SORT_FIELDS: SortFieldDef[] = [ + // General + { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, + { key: 'type', label: 'Typ', type: 'select', group: 'general' }, + { key: 'email_1', label: 'E-Mail 1', type: 'text', group: 'general' }, + { key: 'email_2', label: 'E-Mail 2', type: 'text', group: 'general' }, + { key: 'phone_1', label: 'Telefon 1', type: 'text', group: 'general' }, + { key: 'phone_2', label: 'Telefon 2', type: 'text', group: 'general' }, + { key: 'website', label: 'Website', type: 'text', group: 'general' }, + { key: 'tags', label: 'Tags', type: 'text', group: 'general' }, + + // Company + { key: 'name', label: 'Firmenname', type: 'text', group: 'company' }, + { key: 'code', label: 'Kunden-Nr.', type: 'text', group: 'company' }, + { key: 'accounting_code', label: 'Buchhaltungs-Code', type: 'text', group: 'company' }, + { key: 'vat_code', label: 'USt-IdNr.', type: 'text', group: 'company' }, + { key: 'fiscal_code', label: 'Steuernummer', type: 'text', group: 'company' }, + { key: 'commerce_code', label: 'Handelsregister-Nr.', type: 'text', group: 'company' }, + { key: 'bic', label: 'BIC', type: 'text', group: 'company' }, + { key: 'bank_account', label: 'Bankkonto', type: 'text', group: 'company' }, + + // Person + { key: 'firstname', label: 'Vorname', type: 'text', group: 'person' }, + { key: 'surname', label: 'Nachname', type: 'text', group: 'person' }, + { key: 'suffix', label: 'Suffix', type: 'text', group: 'person' }, + { key: 'gender', label: 'Geschlecht', type: 'select', group: 'person' }, + { key: 'ext_name_line', label: 'Zusatzname', type: 'text', group: 'person' }, + + // Address + { key: 'mailing_street', label: 'Straße (Post)', type: 'text', group: 'address' }, + { key: 'mailing_postalcode', label: 'PLZ (Post)', type: 'text', group: 'address' }, + { key: 'mailing_city', label: 'Stadt (Post)', type: 'text', group: 'address' }, + { key: 'mailing_state', label: 'Bundesland (Post)', type: 'text', group: 'address' }, + { key: 'mailing_country', label: 'Land (Post)', type: 'text', group: 'address' }, + { key: 'visit_city', label: 'Stadt (Besuch)', type: 'text', group: 'address' }, + { key: 'visit_postalcode', label: 'PLZ (Besuch)', type: 'text', group: 'address' }, + { key: 'invoice_city', label: 'Stadt (Rechnung)', type: 'text', group: 'address' }, + { key: 'invoice_postalcode', label: 'PLZ (Rechnung)', type: 'text', group: 'address' }, + + // Notes + { key: 'projectnote', label: 'Projektnotiz', type: 'text', group: 'notes' }, + { key: 'projectnote_title', label: 'Notiztitel', type: 'text', group: 'notes' }, + { key: 'contact_warning', label: 'Warnung', type: 'text', group: 'notes' }, + + // Meta + { key: 'created_at', label: 'Erstellt am', type: 'date', group: 'meta' }, + { key: 'updated_at', label: 'Geändert am', type: 'date', group: 'meta' }, +]; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface SortCondition { + id: string; + field: string; + order: 'asc' | 'desc'; +} + +export interface SortState { + conditions: SortCondition[]; +} + +export const emptySortState: SortState = { + conditions: [], +}; + +// ─── Sort logic ─────────────────────────────────────────────────────────────── + +function getFieldValue(contact: UnifiedContact, field: string): any { + return (contact as any)[field]; +} + +function compareValues(a: any, b: any, fieldType: FieldType): number { + // Handle nulls/undefined — sort them last regardless of order + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + + if (fieldType === 'date') { + const dateA = new Date(a).getTime(); + const dateB = new Date(b).getTime(); + return dateA - dateB; + } + + // Text and select — string comparison + const strA = String(a).toLowerCase(); + const strB = String(b).toLowerCase(); + if (strA < strB) return -1; + if (strA > strB) return 1; + return 0; +} + +export function applySorting(contacts: UnifiedContact[], sortState: SortState): UnifiedContact[] { + if (!sortState.conditions.length) return contacts; + + const sorted = [...contacts]; + sorted.sort((a, b) => { + for (const cond of sortState.conditions) { + const def = SORT_FIELDS.find((f) => f.key === cond.field); + if (!def) continue; + const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type); + if (cmp !== 0) { + return cond.order === 'desc' ? -cmp : cmp; + } + } + return 0; + }); + return sorted; +} + +// ─── Context-sensitive field ordering ────────────────────────────────────────── + +function getOrderedFields(contactType?: 'company' | 'person' | undefined): SortFieldDef[] { + if (contactType === 'company') { + const order = ['general', 'company', 'address', 'notes', 'meta', 'person']; + return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); + } + if (contactType === 'person') { + const order = ['general', 'person', 'address', 'notes', 'meta', 'company']; + return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); + } + const order = ['general', 'company', 'person', 'address', 'notes', 'meta']; + return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface SortPanelProps { + sortState: SortState; + onSortChange: (sortState: SortState) => void; + contactType?: 'company' | 'person' | undefined; +} + +let sortIdCounter = 0; +function newSortId() { + return `sort-${Date.now()}-${++sortIdCounter}`; +} + +export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const panelRef = useRef(null); + const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return; + setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setPanelPos({ top: rect.bottom + 4, left: rect.left }); + } + setOpen(!open); + }; + + const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]); + const activeCount = sortState.conditions.length; + + const addCondition = () => { + onSortChange({ + conditions: [...sortState.conditions, { + id: newSortId(), + field: orderedFields[0]?.key || 'displayname', + order: 'asc' as const, + }], + }); + }; + + const removeCondition = (id: string) => { + onSortChange({ + conditions: sortState.conditions.filter((c) => c.id !== id), + }); + }; + + const updateCondition = (id: string, updates: Partial) => { + onSortChange({ + conditions: sortState.conditions.map((c) => + c.id === id ? { ...c, ...updates } : c + ), + }); + }; + + const clearAll = () => { + onSortChange({ ...emptySortState }); + }; + + const moveCondition = (id: string, direction: 'up' | 'down') => { + const conds = [...sortState.conditions]; + const idx = conds.findIndex((c) => c.id === id); + if (idx < 0) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= conds.length) return; + [conds[idx], conds[newIdx]] = [conds[newIdx], conds[idx]]; + onSortChange({ conditions: conds }); + }; + + // Group fields for dropdown + const groupedFields = useMemo(() => { + const groups: Record = {}; + for (const f of orderedFields) { + const g = f.group || 'general'; + if (!groups[g]) groups[g] = []; + groups[g].push(f); + } + return groups; + }, [orderedFields]); + + const groupLabels: Record = { + general: 'Allgemein', + company: 'Firma', + person: 'Person', + address: 'Adresse', + notes: 'Notizen', + meta: 'Metadaten', + }; + + return ( + <> +
+ +
+ + {open && ( +
+ {/* Header */} +
+ Sortieren +
+ {activeCount > 0 && ( + + )} + +
+
+ + {/* Info text */} + {activeCount === 0 && ( +
+
+ Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden. +
+
+ )} + + {/* Active sort badges */} + {activeCount > 0 && ( +
+ {sortState.conditions.map((cond, idx) => { + const def = SORT_FIELDS.find((f) => f.key === cond.field); + return ( + + {idx + 1}. + {def?.label} + {cond.order === 'asc' ? : } + + + ); + })} +
+ )} + + {/* Sort rows */} +
+ {sortState.conditions.map((cond, idx) => { + const def = SORT_FIELDS.find((f) => f.key === cond.field); + return ( +
+ {/* Priority number + reorder */} +
+ {idx + 1} +
+ + +
+
+ + {/* Field dropdown */} + + + {/* Order toggle */} + + + {/* Remove button */} + +
+ ); + })} +
+ + {/* Footer */} +
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index e9d892f..c6aa0ca 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -21,6 +21,7 @@ import { TagSelector } from '@/components/tags/TagSelector'; import type { Tag } from '@/api/tags'; import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react'; import { FilterPanel, applyFilters, emptyFilterState, type FilterState } from '@/components/contacts/FilterPanel'; +import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel'; import { useUnifiedContacts, useUnifiedContact, @@ -44,6 +45,7 @@ export function ContactsListPage() { const [savedFiltersOpen, setSavedFiltersOpen] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [filterState, setFilterState] = useState(emptyFilterState); + const [sortState, setSortState] = useState(emptySortState); const openWindow = useWindowStore((s) => s.openWindow); // Debounce search @@ -113,8 +115,10 @@ export function ContactsListPage() { } // Apply FilterPanel conditions result = applyFilters(result, filterState); + // Apply SortPanel sorting + result = applySorting(result, sortState); return result; - }, [contacts, tagFilter, filterState]); + }, [contacts, tagFilter, filterState, sortState]); // Handle folder selection const handleSelectFilter = useCallback((filter: ContactFilter) => { @@ -232,26 +236,20 @@ export function ContactsListPage() { ), onClick: () => {}, }, - // Sort dropdown + // SortPanel — SmartSuite-style multi-field sort { - id: 'sort-by', + id: 'sort-panel', plugin: 'contacts', - label: t('common.sort', 'Sortieren'), - type: 'dropdown' as const, + label: 'Sortieren', + type: 'custom' as const, group: 'sort', - icon: , - menuWidth: '200px', - menuOptions: [ - ...sortOptions.map((opt) => ({ - value: `sort-${opt.value}`, label: opt.label, section: t('common.sortBy', 'Sortieren nach'), - active: sortBy === opt.value, onClick: () => { setSortBy(opt.value); setPage(1); }, - })), - { value: 'sep-order', label: '', separator: true, onClick: () => {} }, - { value: 'sort-asc', label: t('common.sortAsc', 'Aufsteigend'), section: t('common.sortOrder', 'Reihenfolge'), - icon: , active: sortOrder === 'asc', onClick: () => handleSortChange(sortBy, 'asc') }, - { value: 'sort-desc', label: t('common.sortDesc', 'Absteigend'), - icon: , active: sortOrder === 'desc', onClick: () => handleSortChange(sortBy, 'desc') }, - ], + customComponent: ( + + ), onClick: () => {}, }, // View mode dropdown (list / table / cards) @@ -313,7 +311,7 @@ export function ContactsListPage() { ]; registerItems('contacts', items); return () => unregisterPlugin('contacts'); - }, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType]); + }, [handleSearch, handleCreate, handleSelectFilter, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState]); return (