// TODO: P2-F11 — Replace hardcoded SORT_FIELDS with shared constant /** * 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'; import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions'; // ─── 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' | 'custom'; } let 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 { if (field.startsWith('custom.')) { const customField = field.slice(7); return contact.custom?.[customField]; } 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, allDefs?: SortFieldDef[]): UnifiedContact[] { if (!sortState.conditions.length) return contacts; const defs = allDefs || SORT_FIELDS; const sorted = [...contacts]; sorted.sort((a, b) => { for (const cond of sortState.conditions) { const def = defs.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, allDefs?: SortFieldDef[]): SortFieldDef[] { const defs = allDefs || SORT_FIELDS; if (contactType === 'company') { const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person']; return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); } if (contactType === 'person') { const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company']; return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); } const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom']; return [...defs].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 }); // Fetch custom field definitions (Feature 4) const { data: customFieldDefs } = useCustomFieldDefinitions('contact'); // Merge custom field definitions into SORT_FIELDS const allSortFields = useMemo(() => { const baseFields = SORT_FIELDS.filter((f) => !f.key.startsWith('custom.')); if (!customFieldDefs || !customFieldDefs.items || customFieldDefs.items.length === 0) return baseFields; const customFields: SortFieldDef[] = 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, }; }); return [...baseFields, ...customFields]; }, [customFieldDefs]); 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(); const panelWidth = Math.min(420, 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, allSortFields), [contactType, allSortFields]); 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', custom: 'Benutzerdefinierte Felder', }; 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 = allSortFields.find((f) => f.key === cond.field); return ( {idx + 1}. {def?.label} {cond.order === 'asc' ? : } ); })}
)} {/* Sort rows */}
{sortState.conditions.map((cond, idx) => { const def = allSortFields.find((f) => f.key === cond.field); return (
{/* Priority number + reorder */}
{idx + 1}
{/* Field dropdown */} {/* Order toggle */} {/* Remove button */}
); })}
{/* Footer */}
)} ); }