// TODO: P2-F12 — Replace hardcoded GROUP_FIELDS with shared constant /** * SmartSuite-style Group Panel for contacts. * Multi-field grouping with priority ordering. */ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Group as GroupIcon, Plus, X } from 'lucide-react'; import type { UnifiedContact } from '@/api/unifiedContacts'; import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions'; // ─── Field definitions ──────────────────────────────────────────────────────── type FieldType = 'text' | 'number' | 'select' | 'date'; interface GroupFieldDef { key: string; label: string; type: FieldType; group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom'; } let GROUP_FIELDS: GroupFieldDef[] = [ // General { key: 'type', label: 'Typ', type: 'select', group: 'general' }, { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, { key: 'email_1', label: 'E-Mail 1', type: 'text', group: 'general' }, { key: 'phone_1', label: 'Telefon 1', 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: 'folder_id', label: 'Ordner-ID', type: 'text', group: '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 GroupCondition { id: string; field: string; } export interface GroupState { conditions: GroupCondition[]; } export const emptyGroupState: GroupState = { conditions: [], }; // ─── Group 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]; } export interface GroupedContacts { key: string; label: string; contacts: UnifiedContact[]; } export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState, allDefs?: GroupFieldDef[]): GroupedContacts[] { const defs = allDefs || GROUP_FIELDS; if (!groupState.conditions.length) { return [{ key: 'all', label: 'Alle', contacts }]; } // Multi-level grouping let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }]; for (const cond of groupState.conditions) { const def = defs.find((f) => f.key === cond.field); if (!def) continue; const newGroups: GroupedContacts[] = []; for (const group of groups) { const subGroups = new Map(); for (const contact of group.contacts) { const val = getFieldValue(contact, cond.field); const groupKey = val == null || val === '' ? '(leer)' : String(val); if (!subGroups.has(groupKey)) { subGroups.set(groupKey, []); } subGroups.get(groupKey)!.push(contact); } // Sort group keys alphabetically const sortedKeys = Array.from(subGroups.keys()).sort((a, b) => { if (a === '(leer)') return 1; if (b === '(leer)') return -1; return a.localeCompare(b); }); for (const key of sortedKeys) { const label = group.key === 'all' ? `${def.label}: ${key}` : `${group.label} > ${key}`; newGroups.push({ key: `${group.key}::${key}`, label, contacts: subGroups.get(key)! }); } } groups = newGroups; } return groups; } // ─── Context-sensitive field ordering ────────────────────────────────────────── function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: GroupFieldDef[]): GroupFieldDef[] { const defs = allDefs || GROUP_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 GroupPanelProps { groupState: GroupState; onGroupChange: (groupState: GroupState) => void; contactType?: 'company' | 'person' | undefined; } let groupIdCounter = 0; function newGroupId() { return `grp-${Date.now()}-${++groupIdCounter}`; } export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) { 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 GROUP_FIELDS const allGroupFields = useMemo(() => { const baseFields = GROUP_FIELDS.filter((f) => !f.key.startsWith('custom.')); if (!customFieldDefs || !customFieldDefs.items || customFieldDefs.items.length === 0) return baseFields; const customFields: GroupFieldDef[] = 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, allGroupFields), [contactType, allGroupFields]); const activeCount = groupState.conditions.length; const addCondition = () => { onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: orderedFields[0]?.key || 'type', }], }); }; const removeCondition = (id: string) => { onGroupChange({ conditions: groupState.conditions.filter((c) => c.id !== id), }); }; const updateCondition = (id: string, updates: Partial) => { onGroupChange({ conditions: groupState.conditions.map((c) => c.id === id ? { ...c, ...updates } : c ), }); }; const clearAll = () => { onGroupChange({ ...emptyGroupState }); }; const moveCondition = (id: string, direction: 'up' | 'down') => { const conds = [...groupState.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]]; onGroupChange({ 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 */}
Gruppierung
{activeCount > 0 && ( )}
{/* Info text */} {activeCount === 0 && (
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.
)} {/* Active group badges */} {activeCount > 0 && (
{groupState.conditions.map((cond, idx) => { const def = allGroupFields.find((f) => f.key === cond.field); return ( {idx + 1}. {def?.label} ); })}
)} {/* Group rows */}
{groupState.conditions.map((cond, idx) => (
{/* Priority number + reorder */}
{idx + 1}
{/* Field dropdown */} {/* Remove button */}
))}
{/* Footer */}
)} ); }