diff --git a/frontend/src/components/contacts/FilterPanel.tsx b/frontend/src/components/contacts/FilterPanel.tsx new file mode 100644 index 0000000..dc9a019 --- /dev/null +++ b/frontend/src/components/contacts/FilterPanel.tsx @@ -0,0 +1,547 @@ +/** + * SmartSuite-style Filter Panel for contacts. + * Multi-condition AND/OR filtering with all UnifiedContact fields. + */ + +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { Filter, Plus, X, ChevronDown } from 'lucide-react'; +import type { UnifiedContact } from '@/api/unifiedContacts'; + +// ─── Field definitions ─────────────────────────────────────────────────────── + +type FieldType = 'text' | 'number' | 'select' | 'date'; + +interface FilterFieldDef { + key: string; + label: string; + type: FieldType; + options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta'; +} + +const FIELD_DEFS: FilterFieldDef[] = [ + // General + { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, + { key: 'type', label: 'Typ', type: 'select', group: 'general', options: [ + { value: 'company', label: 'Firma' }, + { value: 'person', label: 'Person' }, + ]}, + { 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-focused + { 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: 'vendor_accounting_code', label: 'Lieferanten-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' }, + { key: 'purchase_number', label: 'Bestellnummer', type: 'text', group: 'company' }, + + // Person-focused + { 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', options: [ + { value: 'm', label: 'Männlich' }, + { value: 'f', label: 'Weiblich' }, + { value: 'd', label: 'Divers' }, + ]}, + { key: 'ext_name_line', label: 'Zusatzname', type: 'text', group: 'person' }, + + // Address (mailing) + { key: 'mailing_street', label: 'Straße (Post)', type: 'text', group: 'address' }, + { key: 'mailing_number', label: 'Hausnr. (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' }, + // Visit + { key: 'visit_street', label: 'Straße (Besuch)', type: 'text', group: 'address' }, + { key: 'visit_city', label: 'Stadt (Besuch)', type: 'text', group: 'address' }, + { key: 'visit_postalcode', label: 'PLZ (Besuch)', type: 'text', group: 'address' }, + // Invoice + { 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' }, +]; + +// ─── Operators ──────────────────────────────────────────────────────────────── + +interface OperatorDef { + value: string; + label: string; + needsValue: boolean; +} + +const TEXT_OPERATORS: OperatorDef[] = [ + { value: 'contains', label: 'enthält', needsValue: true }, + { value: 'equals', label: 'ist gleich', needsValue: true }, + { value: 'startsWith', label: 'beginnt mit', needsValue: true }, + { value: 'endsWith', label: 'endet mit', needsValue: true }, + { value: 'isEmpty', label: 'ist leer', needsValue: false }, + { value: 'isNotEmpty', label: 'ist nicht leer', needsValue: false }, +]; + +const SELECT_OPERATORS: OperatorDef[] = [ + { value: 'equals', label: 'ist', needsValue: true }, + { value: 'notEquals', label: 'ist nicht', needsValue: true }, +]; + +const DATE_OPERATORS: OperatorDef[] = [ + { value: 'before', label: 'vor', needsValue: true }, + { value: 'after', label: 'nach', needsValue: true }, + { value: 'on', label: 'an', needsValue: true }, + { value: 'isEmpty', label: 'ist leer', needsValue: false }, + { value: 'isNotEmpty', label: 'ist nicht leer', needsValue: false }, +]; + +function getOperators(fieldType: FieldType): OperatorDef[] { + if (fieldType === 'select') return SELECT_OPERATORS; + if (fieldType === 'date') return DATE_OPERATORS; + return TEXT_OPERATORS; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface FilterCondition { + id: string; + field: string; + operator: string; + value: string; +} + +export interface FilterState { + logic: 'AND' | 'OR'; + conditions: FilterCondition[]; +} + +export const emptyFilterState: FilterState = { + logic: 'AND', + conditions: [], +}; + +// ─── Filter logic ───────────────────────────────────────────────────────────── + +function getFieldValue(contact: UnifiedContact, field: string): any { + return (contact as any)[field]; +} + +function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean { + const def = FIELD_DEFS.find((f) => f.key === cond.field); + if (!def) return true; + const val = getFieldValue(contact, cond.field); + const op = cond.operator; + const searchVal = cond.value.toLowerCase().trim(); + + switch (op) { + case 'contains': + return val != null && String(val).toLowerCase().includes(searchVal); + case 'equals': + return val != null && String(val).toLowerCase() === searchVal; + case 'notEquals': + return val == null || String(val).toLowerCase() !== searchVal; + case 'startsWith': + return val != null && String(val).toLowerCase().startsWith(searchVal); + case 'endsWith': + return val != null && String(val).toLowerCase().endsWith(searchVal); + case 'isEmpty': + return val == null || val === '' || val === undefined; + case 'isNotEmpty': + return val != null && val !== '' && val !== undefined; + case 'before': + return val != null && new Date(val) < new Date(cond.value); + case 'after': + return val != null && new Date(val) > new Date(cond.value); + case 'on': + return val != null && new Date(val).toDateString() === new Date(cond.value).toDateString(); + default: + return true; + } +} + +export function applyFilters(contacts: UnifiedContact[], filters: FilterState): UnifiedContact[] { + if (!filters.conditions.length) return contacts; + if (filters.logic === 'AND') { + return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond))); + } else { + return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond))); + } +} + +// ─── Context-sensitive field ordering ────────────────────────────────────────── + +function getOrderedFields(contactType?: 'company' | 'person' | undefined): FilterFieldDef[] { + 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 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 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 ai = order.indexOf(a.group || 'general'); + const bi = order.indexOf(b.group || 'general'); + return ai - bi; + }); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface FilterPanelProps { + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + contactType?: 'company' | 'person' | undefined; +} + +let condIdCounter = 0; +function newConditionId() { + return `cond-${Date.now()}-${++condIdCounter}`; +} + +export function FilterPanel({ filters, onFiltersChange, contactType }: FilterPanelProps) { + 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 = filters.conditions.length; + + const addCondition = () => { + onFiltersChange({ + ...filters, + conditions: [...filters.conditions, { + id: newConditionId(), + field: orderedFields[0]?.key || 'displayname', + operator: 'contains', + value: '', + }], + }); + }; + + const removeCondition = (id: string) => { + onFiltersChange({ + ...filters, + conditions: filters.conditions.filter((c) => c.id !== id), + }); + }; + + const updateCondition = (id: string, updates: Partial) => { + onFiltersChange({ + ...filters, + conditions: filters.conditions.map((c) => + c.id === id ? { ...c, ...updates } : c + ), + }); + }; + + const clearAll = () => { + onFiltersChange({ ...emptyFilterState }); + }; + + const toggleLogic = () => { + onFiltersChange({ ...filters, logic: filters.logic === 'AND' ? 'OR' : 'AND' }); + }; + + // Group fields by group for the 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', + finance: 'Finanzen', + notes: 'Notizen', + meta: 'Metadaten', + }; + + return ( + <> +
+ +
+ + {open && ( +
+ {/* Header */} +
+ Filter +
+ {activeCount > 0 && ( + + )} + +
+
+ + {/* Logic toggle */} + {activeCount > 0 && ( +
+ Bedingungen verknüpfen: + + +
+ )} + + {/* Active filter badges */} + {activeCount > 0 && ( +
+ {filters.conditions.map((cond) => { + const def = FIELD_DEFS.find((f) => f.key === cond.field); + const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator); + return ( + + {def?.label} {op?.label} {cond.value && `"${cond.value}"`} + + + ); + })} +
+ )} + + {/* Filter rows */} +
+ {filters.conditions.length === 0 && ( +
+ Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen. +
+ )} + {filters.conditions.map((cond, idx) => { + const def = FIELD_DEFS.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 && ( + + {filters.logic} + + )} + {idx === 0 &&
} + + {/* Field dropdown */} + + + {/* Operator dropdown */} + + + {/* Value input */} + {currentOp?.needsValue ? ( + def?.type === 'select' ? ( + + ) : def?.type === 'date' ? ( + updateCondition(cond.id, { value: e.target.value })} + className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" + /> + ) : ( + updateCondition(cond.id, { value: e.target.value })} + placeholder="Wert…" + className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" + /> + ) + ) : ( +
+ )} + + {/* Remove button */} + +
+ ); + })} +
+ + {/* Footer */} +
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/components/layout/PluginToolbar.tsx b/frontend/src/components/layout/PluginToolbar.tsx index d7a24d6..9dc690b 100644 --- a/frontend/src/components/layout/PluginToolbar.tsx +++ b/frontend/src/components/layout/PluginToolbar.tsx @@ -228,6 +228,7 @@ export function PluginToolbar() { if (item.type === 'search') return ; if (item.type === 'select') return ; if (item.type === 'dropdown') return ; + if (item.type === 'custom' && item.customComponent) return {item.customComponent}; return ; })}
diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index e88cedc..e9d892f 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -19,7 +19,8 @@ import { SavedFilters } from '@/components/SavedFilters'; import { SavedFilterBar } from '@/components/common/SavedFilterBar'; import { TagSelector } from '@/components/tags/TagSelector'; import type { Tag } from '@/api/tags'; -import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, Filter, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react'; +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 { useUnifiedContacts, useUnifiedContact, @@ -42,6 +43,7 @@ export function ContactsListPage() { const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [savedFiltersOpen, setSavedFiltersOpen] = useState(false); const [selectedTags, setSelectedTags] = useState([]); + const [filterState, setFilterState] = useState(emptyFilterState); const openWindow = useWindowStore((s) => s.openWindow); // Debounce search @@ -100,14 +102,19 @@ export function ContactsListPage() { return Array.from(tagSet).sort(); }, [contacts]); - // Filter by tag client-side if tag filter is active + // Filter by tag client-side if tag filter is active, then apply FilterPanel conditions const filteredContacts = useMemo(() => { - if (!tagFilter) return contacts; - return contacts.filter((c) => { - if (!c.tags) return false; - return c.tags.split(',').map((t) => t.trim()).includes(tagFilter); - }); - }, [contacts, tagFilter]); + let result = contacts; + if (tagFilter) { + result = result.filter((c) => { + if (!c.tags) return false; + return c.tags.split(',').map((t) => t.trim()).includes(tagFilter); + }); + } + // Apply FilterPanel conditions + result = applyFilters(result, filterState); + return result; + }, [contacts, tagFilter, filterState]); // Handle folder selection const handleSelectFilter = useCallback((filter: ContactFilter) => { @@ -209,32 +216,38 @@ export function ContactsListPage() { onSearch: handleSearch, onClick: () => {}, }, - // Unified filter dropdown — filter + sort in one panel + // FilterPanel — SmartSuite-style multi-condition filter { - id: 'filter-sort', + id: 'filter-panel', plugin: 'contacts', - label: t('common.filter', 'Filter'), - type: 'dropdown' as const, + label: 'Filter', + type: 'custom' as const, group: 'filter', - icon: , - iconOnly: true, - menuWidth: '240px', + customComponent: ( + + ), + onClick: () => {}, + }, + // Sort dropdown + { + id: 'sort-by', + plugin: 'contacts', + label: t('common.sort', 'Sortieren'), + type: 'dropdown' as const, + group: 'sort', + icon: , + menuWidth: '200px', menuOptions: [ - // Section: Filter - { value: 'all', label: t('common.all'), section: t('common.filter', 'Filter'), active: selectedFilter === 'all', onClick: () => handleSelectFilter('all') }, - { value: 'company', label: t('contacts.companies'), active: selectedFilter === 'company', onClick: () => handleSelectFilter('company') }, - { value: 'person', label: t('contacts.persons'), active: selectedFilter === 'person', onClick: () => handleSelectFilter('person') }, - // Separator - { value: 'sep-sort', label: '', separator: true, onClick: () => {} }, - // Section: Sort by ...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); }, })), - // Separator { value: 'sep-order', label: '', separator: true, onClick: () => {} }, - // Section: Sort order - { value: 'sort-asc', label: t('common.sortAsc', 'Aufsteigend'), section: t('common.sortOrder', 'Sortierreihenfolge'), + { 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') }, @@ -300,7 +313,7 @@ export function ContactsListPage() { ]; registerItems('contacts', items); return () => unregisterPlugin('contacts'); - }, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions]); + }, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType]); return (
diff --git a/frontend/src/store/pluginToolbarStore.ts b/frontend/src/store/pluginToolbarStore.ts index bde9f3a..0653d0c 100644 --- a/frontend/src/store/pluginToolbarStore.ts +++ b/frontend/src/store/pluginToolbarStore.ts @@ -9,7 +9,7 @@ export interface ToolbarItem { group?: string; disabled?: boolean; active?: boolean; - type?: 'button' | 'search' | 'select' | 'dropdown'; + type?: 'button' | 'search' | 'select' | 'dropdown' | 'custom'; searchPlaceholder?: string; onSearch?: (query: string) => void; selectOptions?: { value: string; label: string }[]; @@ -18,6 +18,7 @@ export interface ToolbarItem { menuOptions?: { value: string; label: string; icon?: React.ReactNode; active?: boolean; onClick: () => void; section?: string; separator?: boolean; disabled?: boolean }[]; menuWidth?: string; iconOnly?: boolean; + customComponent?: React.ReactNode; } interface PluginToolbarState {