diff --git a/frontend/src/components/mail/MailFilterPanel.tsx b/frontend/src/components/mail/MailFilterPanel.tsx new file mode 100644 index 0000000..50f7c67 --- /dev/null +++ b/frontend/src/components/mail/MailFilterPanel.tsx @@ -0,0 +1,451 @@ +/** + * SmartSuite-style Filter Panel for Mail. + * Multi-condition AND/OR filtering with mail fields. + * Based on contacts/FilterPanel.tsx + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { Filter, Plus, X, Bookmark } from 'lucide-react'; + +// ─── Field definitions ─────────────────────────────────────────────────────── + +type FieldType = 'text' | 'number' | 'select' | 'date'; + +interface FilterFieldDef { + key: string; + label: string; + type: FieldType; + options?: { value: string; label: string }[]; + group?: 'general' | 'meta'; +} + +const FIELD_DEFS: FilterFieldDef[] = [ + // General + { key: 'subject', label: 'Betreff', type: 'text', group: 'general' }, + { key: 'from', label: 'Absender', type: 'text', group: 'general' }, + { key: 'to', label: 'Empfänger', type: 'text', group: 'general' }, + { key: 'body', label: 'Inhalt', type: 'text', group: 'general' }, + { key: 'folder_name', label: 'Ordner', type: 'text', group: 'general' }, + { key: 'is_read', label: 'Gelesen', type: 'select', group: 'general', options: [ + { value: 'true', label: 'Gelesen' }, + { value: 'false', label: 'Ungelesen' }, + ]}, + { key: 'is_flagged', label: 'Markiert', type: 'select', group: 'general', options: [ + { value: 'true', label: 'Markiert' }, + { value: 'false', label: 'Nicht markiert' }, + ]}, + { key: 'has_attachments', label: 'Anhänge', type: 'select', group: 'general', options: [ + { value: 'true', label: 'Mit Anhängen' }, + { value: 'false', label: 'Ohne Anhänge' }, + ]}, + // Meta + { key: 'date', label: 'Datum', type: 'date', group: 'meta' }, + { key: 'size', label: 'Größe (Bytes)', type: 'number', 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 }, +]; + +const NUMBER_OPERATORS: OperatorDef[] = [ + { value: 'equals', label: 'ist gleich', needsValue: true }, + { value: 'greaterThan', label: 'größer als', needsValue: true }, + { value: 'lessThan', label: 'kleiner als', needsValue: true }, +]; + +function getOperators(fieldType: FieldType): OperatorDef[] { + if (fieldType === 'select') return SELECT_OPERATORS; + if (fieldType === 'date') return DATE_OPERATORS; + if (fieldType === 'number') return NUMBER_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(mail: any, field: string): any { + return (mail as any)[field]; +} + +function matchesCondition(mail: any, cond: FilterCondition): boolean { + const def = FIELD_DEFS.find((f) => f.key === cond.field); + if (!def) return true; + const val = getFieldValue(mail, 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': + if (def.type === 'select') return String(val) === cond.value; + return val != null && String(val).toLowerCase() === searchVal; + case 'notEquals': + if (def.type === 'select') return String(val) !== cond.value; + 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(); + case 'greaterThan': + return val != null && Number(val) > Number(cond.value); + case 'lessThan': + return val != null && Number(val) < Number(cond.value); + default: + return true; + } +} + +export function applyFilters(mails: any[], filters: FilterState): any[] { + if (!filters.conditions.length) return mails; + if (filters.logic === 'AND') { + return mails.filter((m) => filters.conditions.every((cond) => matchesCondition(m, cond))); + } else { + return mails.filter((m) => filters.conditions.some((cond) => matchesCondition(m, cond))); + } +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export interface SavedFilter { + id: string; + name: string; + filterState: FilterState; +} + +interface MailFilterPanelProps { + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + savedFilters?: SavedFilter[]; + onSaveFilter?: (name: string, filterState: FilterState) => void; + onLoadFilter?: (filterState: FilterState) => void; + onDeleteFilter?: (id: string) => void; +} + +let condIdCounter = 0; +function newConditionId() { + return `mcond-${Date.now()}-${++condIdCounter}`; +} + +export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) { + 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(); + 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 activeCount = filters.conditions.length; + + const addCondition = () => { + onFiltersChange({ + ...filters, + conditions: [...filters.conditions, { + id: newConditionId(), + field: FIELD_DEFS[0].key, + 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' }); + }; + + const handleSaveFilter = () => { + if (!onSaveFilter) return; + if (filters.conditions.length === 0) return; + const name = window.prompt('Name für diesen Filter:', 'Mein Filter'); + if (!name) return; + onSaveFilter(name, { ...filters, conditions: filters.conditions.map((c) => ({ ...c })) }); + }; + + const groupedFields: Record = {}; + for (const f of FIELD_DEFS) { + const g = f.group || 'general'; + if (!groupedFields[g]) groupedFields[g] = []; + groupedFields[g].push(f); + } + + const groupLabels: Record = { + general: 'Allgemein', + 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}"`} + + + ); + })} +
+ )} + + {/* Saved filters */} + {savedFilters.length > 0 && ( +
+
Gespeicherte Filter
+
+ {savedFilters.map((sf) => ( +
+ + +
+ ))} +
+
+ )} + + {/* 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 ( +
+ {idx > 0 && {filters.logic}} + {idx === 0 &&
} + + + + + + {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" /> + ) + ) : ( +
+ ) + + +
+ ); + })} +
+ + {/* Footer */} +
+
+ + {activeCount > 0 && onSaveFilter && ( + + )} +
+ +
+
+ )} + + ); +} diff --git a/frontend/src/components/mail/MailGroupPanel.tsx b/frontend/src/components/mail/MailGroupPanel.tsx new file mode 100644 index 0000000..b7d0b4f --- /dev/null +++ b/frontend/src/components/mail/MailGroupPanel.tsx @@ -0,0 +1,189 @@ +/** + * SmartSuite-style Group Panel for Mail. + * Multi-field grouping. + * Based on contacts/GroupPanel.tsx + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { Group as GroupIcon, Plus, X } from 'lucide-react'; + +type FieldType = 'text' | 'number' | 'date'; + +interface GroupFieldDef { + key: string; + label: string; + type: FieldType; + group?: string; +} + +const GROUP_FIELDS: GroupFieldDef[] = [ + { key: 'from', label: 'Absender', type: 'text', group: 'Allgemein' }, + { key: 'folder_name', label: 'Ordner', type: 'text', group: 'Allgemein' }, + { key: 'is_read', label: 'Gelesen', type: 'text', group: 'Allgemein' }, + { key: 'is_flagged', label: 'Markiert', type: 'text', group: 'Allgemein' }, + { key: 'has_attachments', label: 'Anhänge', type: 'text', group: 'Allgemein' }, + { key: 'date', label: 'Datum', type: 'date', group: 'Metadaten' }, +]; + +export interface GroupCondition { + id: string; + field: string; +} + +export interface GroupState { + conditions: GroupCondition[]; +} + +export const emptyGroupState: GroupState = { + conditions: [], +}; + +function getFieldValue(mail: any, field: string): any { + return (mail as any)[field]; +} + +export interface GroupedMails { + key: string; + label: string; + mails: any[]; + subGroups?: GroupedMails[]; +} + +export function applyGrouping(mails: any[], groupState: GroupState): GroupedMails[] { + if (!groupState.conditions.length) return [{ key: 'all', label: 'Alle', mails }]; + const defs = GROUP_FIELDS; + + function groupRecursive(items: any[], conditions: GroupCondition[], depth: number): GroupedMails[] { + if (depth >= conditions.length) return [{ key: 'all', label: 'Alle', mails: items }]; + const cond = conditions[depth]; + const def = defs.find((f) => f.key === cond.field); + if (!def) return [{ key: 'all', label: 'Alle', mails: items }]; + const subGroups = new Map(); + for (const mail of items) { + const val = getFieldValue(mail, cond.field); + const groupKey = val == null || val === '' ? '(leer)' : String(val); + if (!subGroups.has(groupKey)) subGroups.set(groupKey, []); + subGroups.get(groupKey)!.push(mail); + } + const sortedKeys = Array.from(subGroups.keys()).sort(); + return sortedKeys.map((key) => { + const groupMails = subGroups.get(key)!; + const subGroups = groupRecursive(groupMails, conditions, depth + 1); + if (subGroups.length === 1 && subGroups[0].key === 'all') { + return { key, label: key, mails: groupMails }; + } + return { key, label: key, mails: [], subGroups }; + }); + } + + return groupRecursive(mails, groupState.conditions, 0); +} + +let groupIdCounter = 0; +function newGroupId() { + return `mgrp-${Date.now()}-${++groupIdCounter}`; +} + +interface MailGroupPanelProps { + groupState: GroupState; + onGroupChange: (groupState: GroupState) => void; +} + +export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) { + 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(); + const panelWidth = Math.min(400, 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 activeCount = groupState.conditions.length; + + const addCondition = () => { + onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: 'from' }] }); + }; + + 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 groupedFields: Record = {}; + for (const f of GROUP_FIELDS) { + const g = f.group || 'Allgemein'; + if (!groupedFields[g]) groupedFields[g] = []; + groupedFields[g].push(f); + } + + return ( + <> +
+ +
+ {open && ( +
+
+ Gruppierung +
+ {activeCount > 0 && } + +
+
+
+ {groupState.conditions.length === 0 &&
Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.
} + {groupState.conditions.map((cond, idx) => ( +
+ {idx + 1}. + + +
+ ))} +
+
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/components/mail/MailSortPanel.tsx b/frontend/src/components/mail/MailSortPanel.tsx new file mode 100644 index 0000000..f0e36d2 --- /dev/null +++ b/frontend/src/components/mail/MailSortPanel.tsx @@ -0,0 +1,191 @@ +/** + * SmartSuite-style Sort Panel for Mail. + * Multi-field sorting with priority order. + * Based on contacts/SortPanel.tsx + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react'; + +type FieldType = 'text' | 'number' | 'date'; + +interface SortFieldDef { + key: string; + label: string; + type: FieldType; + group?: string; +} + +const SORT_FIELDS: SortFieldDef[] = [ + { key: 'date', label: 'Datum', type: 'date', group: 'Allgemein' }, + { key: 'from', label: 'Absender', type: 'text', group: 'Allgemein' }, + { key: 'subject', label: 'Betreff', type: 'text', group: 'Allgemein' }, + { key: 'to', label: 'Empfänger', type: 'text', group: 'Allgemein' }, + { key: 'size', label: 'Größe', type: 'number', group: 'Metadaten' }, + { key: 'is_read', label: 'Gelesen', type: 'text', group: 'Metadaten' }, + { key: 'is_flagged', label: 'Markiert', type: 'text', group: 'Metadaten' }, +]; + +export interface SortCondition { + id: string; + field: string; + direction: 'asc' | 'desc'; +} + +export interface SortState { + conditions: SortCondition[]; +} + +export const emptySortState: SortState = { + conditions: [], +}; + +function getFieldValue(mail: any, field: string): any { + return (mail as any)[field]; +} + +function compareValues(a: any, b: any, fieldType: FieldType): number { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + if (fieldType === 'number') return Number(a) - Number(b); + if (fieldType === 'date') { + const dateA = new Date(a).getTime(); + const dateB = new Date(b).getTime(); + return dateA - dateB; + } + const strA = String(a).toLowerCase(); + const strB = String(b).toLowerCase(); + return strA.localeCompare(strB); +} + +export function applySorting(mails: any[], sortState: SortState): any[] { + if (!sortState.conditions.length) return mails; + const sorted = [...mails]; + 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.direction === 'desc' ? -cmp : cmp; + } + return 0; + }); + return sorted; +} + +let sortIdCounter = 0; +function newSortId() { + return `msort-${Date.now()}-${++sortIdCounter}`; +} + +interface MailSortPanelProps { + sortState: SortState; + onSortChange: (sortState: SortState) => void; +} + +export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) { + 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(); + const panelWidth = Math.min(400, 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 activeCount = sortState.conditions.length; + + const addCondition = () => { + onSortChange({ + conditions: [...sortState.conditions, { id: newSortId(), field: 'date', direction: 'desc' }], + }); + }; + + 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 groupedFields: Record = {}; + for (const f of SORT_FIELDS) { + const g = f.group || 'Allgemein'; + if (!groupedFields[g]) groupedFields[g] = []; + groupedFields[g].push(f); + } + + return ( + <> +
+ +
+ {open && ( +
+
+ Sortieren +
+ {activeCount > 0 && } + +
+
+
+ {sortState.conditions.length === 0 &&
Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.
} + {sortState.conditions.map((cond, idx) => ( +
+ {idx + 1}. + + + +
+ ))} +
+
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index a9719e1..7153fd5 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -452,16 +452,6 @@ export function ContactsListPage() { ), onClick: () => {}, }, - // Saved filters - { - id: 'saved-filters', - plugin: 'contacts', - label: 'Gespeicherte Filter', - type: 'button' as const, - group: 'actions', - icon: , - onClick: () => setSavedFiltersOpen(true), - }, // Print — icon only, right side ...(canAccess('contacts:read') ? [{ id: 'print', diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index e6a3884..ec853d1 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -18,7 +18,10 @@ import { MailDetail } from '@/components/mail/MailDetail'; import { MailComposeForm, type ComposeMode } from '@/components/mail/MailComposeForm'; import { useWindowStore } from '@/store/windowStore'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; -import { ArrowRight, ArrowUpDown, Bookmark, Check, ChevronLeft, ExternalLink, Loader2, Plus, Redo2, Trash2, TrendingUp, Undo2 } from 'lucide-react'; +import { ArrowRight, Check, ChevronLeft, ExternalLink, Loader2, Plus, Redo2, Trash2, TrendingUp, Undo2 } from 'lucide-react'; +import { MailFilterPanel, type FilterState as MailFilterState, emptyFilterState as emptyMailFilterState } from '@/components/mail/MailFilterPanel'; +import { MailSortPanel, type SortState as MailSortState, emptySortState as emptyMailSortState } from '@/components/mail/MailSortPanel'; +import { MailGroupPanel, type GroupState as MailGroupState, emptyGroupState as emptyMailGroupState } from '@/components/mail/MailGroupPanel'; import type { Tag } from '@/api/tags'; import { useSavedFilters } from '@/api/savedFilters'; import { @@ -87,6 +90,9 @@ export function MailPage() { const [isSyncing, setIsSyncing] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const { data: savedFilters } = useSavedFilters('mail'); + const [mailFilterState, setMailFilterState] = useState(emptyMailFilterState); + const [mailSortState, setMailSortState] = useState(emptyMailSortState); + const [mailGroupState, setMailGroupState] = useState(emptyMailGroupState); // Ref to track the current folder ID for async callbacks (prevents race conditions) const selectedFolderIdRef = useRef(null); @@ -594,44 +600,59 @@ export function MailPage() { onSearch: handleSearch, onClick: () => {}, }, - // Filter dropdown — sort + saved filters (like Contacts) + // FilterPanel — Multi-condition filter (like Contacts) { - id: 'filter-sort', + id: 'filter-panel', plugin: 'mail', - label: 'Sortieren', - type: 'dropdown' as const, + label: 'Filter', + type: 'custom' as const, group: 'filter', - icon: , - menuWidth: '200px', - menuOptions: [ - { value: 'date-desc', label: 'Datum ↓ (neueste zuerst)', active: sortBy === 'date' && sortOrder === 'desc', onClick: () => { setSortBy('date'); setSortOrder('desc'); } }, - { value: 'date-asc', label: 'Datum ↑ (älteste zuerst)', active: sortBy === 'date' && sortOrder === 'asc', onClick: () => { setSortBy('date'); setSortOrder('asc'); } }, - { value: 'from-asc', label: 'Absender A-Z', active: sortBy === 'from' && sortOrder === 'asc', onClick: () => { setSortBy('from'); setSortOrder('asc'); } }, - { value: 'from-desc', label: 'Absender Z-A', active: sortBy === 'from' && sortOrder === 'desc', onClick: () => { setSortBy('from'); setSortOrder('desc'); } }, - { value: 'subject-asc', label: 'Betreff A-Z', active: sortBy === 'subject' && sortOrder === 'asc', onClick: () => { setSortBy('subject'); setSortOrder('asc'); } }, - { value: 'subject-desc', label: 'Betreff Z-A', active: sortBy === 'subject' && sortOrder === 'desc', onClick: () => { setSortBy('subject'); setSortOrder('desc'); } }, - ], + customComponent: ( + ({ id: f.id, name: f.name, filterState: f.filter_criteria || emptyMailFilterState }))} + onSaveFilter={(name, state) => { + // TODO: save via API + console.log('Save filter', name, state); + }} + onLoadFilter={(state) => setMailFilterState(state)} + onDeleteFilter={(id) => { + // TODO: delete via API + console.log('Delete filter', id); + }} + /> + ), onClick: () => {}, }, + // SortPanel — Multi-field sort (like Contacts) { - id: 'filter-saved', + id: 'sort-panel', plugin: 'mail', - label: 'Gespeicherte Filter', - type: 'dropdown' as const, - group: 'filter', - icon: , - menuWidth: '220px', - menuOptions: (savedFilters || []).length > 0 - ? (savedFilters || []).map((f: any) => ({ - value: f.id, - label: f.name, - onClick: () => { - if (f.filter_criteria?.search !== undefined) handleSearch(f.filter_criteria.search); - if (f.filter_criteria?.sortBy) setSortBy(f.filter_criteria.sortBy); - if (f.filter_criteria?.sortOrder) setSortOrder(f.filter_criteria.sortOrder); - }, - })) - : [{ value: 'none', label: 'Keine gespeicherten Filter', disabled: true, onClick: () => {} }], + label: 'Sortieren', + type: 'custom' as const, + group: 'sort', + customComponent: ( + + ), + onClick: () => {}, + }, + // GroupPanel — Multi-field grouping (like Contacts) + { + id: 'group-panel', + plugin: 'mail', + label: 'Gruppierung', + type: 'custom' as const, + group: 'group', + customComponent: ( + + ), onClick: () => {}, }, {