feat: Mail FilterPanel, SortPanel, GroupPanel like Contacts + remove saved-filters button from Contacts and Mail
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(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<FilterCondition>) => {
|
||||||
|
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<string, FilterFieldDef[]> = {};
|
||||||
|
for (const f of FIELD_DEFS) {
|
||||||
|
const g = f.group || 'general';
|
||||||
|
if (!groupedFields[g]) groupedFields[g] = [];
|
||||||
|
groupedFields[g].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupLabels: Record<string, string> = {
|
||||||
|
general: 'Allgemein',
|
||||||
|
meta: 'Metadaten',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
title="Filter"
|
||||||
|
aria-label="Filter"
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
|
transition-colors duration-100 cursor-pointer relative
|
||||||
|
${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Filter className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full bg-primary-600 text-white">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: panelPos.top,
|
||||||
|
left: panelPos.left,
|
||||||
|
width: 'min(480px, 90vw)',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 3000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
|
<span className="text-sm font-semibold text-secondary-800">Filter</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">
|
||||||
|
Alle löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logic toggle */}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||||
|
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span>
|
||||||
|
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'AND' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>UND</button>
|
||||||
|
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active filter badges */}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
|
||||||
|
{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 (
|
||||||
|
<span key={cond.id} className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full bg-primary-50 text-primary-700 border border-primary-200">
|
||||||
|
{def?.label} {op?.label} {cond.value && `"${cond.value}"`}
|
||||||
|
<button onClick={() => removeCondition(cond.id)} className="hover:text-red-600"><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved filters */}
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-secondary-100">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{savedFilters.map((sf) => (
|
||||||
|
<div key={sf.id} className="flex items-center gap-1 group">
|
||||||
|
<button onClick={() => onLoadFilter?.(sf.filterState)} className="flex-1 flex items-center gap-1.5 px-2 py-1 text-xs text-left rounded text-secondary-700 hover:bg-secondary-100 transition-colors">
|
||||||
|
<Bookmark className="w-3 h-3 text-primary-500 flex-shrink-0" />
|
||||||
|
<span className="truncate">{sf.name}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDeleteFilter?.(sf.id)} className="opacity-0 group-hover:opacity-100 p-1 rounded text-secondary-400 hover:text-red-600 transition-opacity">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter rows */}
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
{filters.conditions.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-xs text-secondary-400">
|
||||||
|
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<div key={cond.id} className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{idx > 0 && <span className="text-[10px] font-bold text-primary-600 w-8 text-center">{filters.logic}</span>}
|
||||||
|
{idx === 0 && <div className="w-8" />}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={cond.field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newDef = FIELD_DEFS.find((f) => f.key === e.target.value);
|
||||||
|
const newOps = getOperators(newDef?.type || 'text');
|
||||||
|
updateCondition(cond.id, { field: e.target.value, operator: newOps[0].value, value: '' });
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
|
>
|
||||||
|
{Object.entries(groupedFields).map(([group, fields]) => (
|
||||||
|
<optgroup key={group} label={groupLabels[group] || group}>
|
||||||
|
{fields.map((f) => (<option key={f.key} value={f.key}>{f.label}</option>))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(cond.id, { operator: e.target.value })}
|
||||||
|
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
|
>
|
||||||
|
{operators.map((op) => (<option key={op.value} value={op.value}>{op.label}</option>))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{currentOp?.needsValue ? (
|
||||||
|
def?.type === 'select' ? (
|
||||||
|
<select value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||||
|
</select>
|
||||||
|
) : def?.type === 'date' ? (
|
||||||
|
<input type="date" value={cond.value} onChange={(e) => 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" />
|
||||||
|
) : (
|
||||||
|
<input type="text" value={cond.value} onChange={(e) => 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" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-24" />
|
||||||
|
)
|
||||||
|
|
||||||
|
<button onClick={() => removeCondition(cond.id)} className="p-1 rounded hover:bg-red-50 text-secondary-400 hover:text-red-600 transition-colors flex-shrink-0">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Bedingung hinzufügen
|
||||||
|
</button>
|
||||||
|
{activeCount > 0 && onSaveFilter && (
|
||||||
|
<button onClick={handleSaveFilter} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
Filter speichern
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, any[]>();
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(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<GroupCondition>) => {
|
||||||
|
onGroupChange({ conditions: groupState.conditions.map((c) => (c.id === id ? { ...c, ...updates } : c)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => onGroupChange({ ...emptyGroupState });
|
||||||
|
|
||||||
|
const groupedFields: Record<string, GroupFieldDef[]> = {};
|
||||||
|
for (const f of GROUP_FIELDS) {
|
||||||
|
const g = f.group || 'Allgemein';
|
||||||
|
if (!groupedFields[g]) groupedFields[g] = [];
|
||||||
|
groupedFields[g].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
title="Gruppierung"
|
||||||
|
aria-label="Gruppierung"
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||||
|
>
|
||||||
|
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full bg-primary-600 text-white">{activeCount}</span>
|
||||||
|
)}
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div ref={panelRef} className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg" style={{ top: panelPos.top, left: panelPos.left, width: 'min(400px, 90vw)', maxHeight: '70vh', overflowY: 'auto', zIndex: 3000 }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
|
<span className="text-sm font-semibold text-secondary-800">Gruppierung</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>}
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
{groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>}
|
||||||
|
{groupState.conditions.map((cond, idx) => (
|
||||||
|
<div key={cond.id} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
||||||
|
<select value={cond.field} onChange={(e) => updateCondition(cond.id, { field: e.target.value })} className="flex-1 min-w-0 px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
|
||||||
|
{Object.entries(groupedFields).map(([group, fields]) => (
|
||||||
|
<optgroup key={group} label={group}>{fields.map((f) => (<option key={f.key} value={f.key}>{f.label}</option>))}</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => removeCondition(cond.id)} className="p-1 rounded hover:bg-red-50 text-secondary-400 hover:text-red-600 transition-colors flex-shrink-0"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button>
|
||||||
|
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(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<SortCondition>) => {
|
||||||
|
onSortChange({
|
||||||
|
conditions: sortState.conditions.map((c) => (c.id === id ? { ...c, ...updates } : c)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => onSortChange({ ...emptySortState });
|
||||||
|
|
||||||
|
const groupedFields: Record<string, SortFieldDef[]> = {};
|
||||||
|
for (const f of SORT_FIELDS) {
|
||||||
|
const g = f.group || 'Allgemein';
|
||||||
|
if (!groupedFields[g]) groupedFields[g] = [];
|
||||||
|
groupedFields[g].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
title="Sortieren"
|
||||||
|
aria-label="Sortieren"
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full bg-primary-600 text-white">{activeCount}</span>
|
||||||
|
)}
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div ref={panelRef} className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg" style={{ top: panelPos.top, left: panelPos.left, width: 'min(400px, 90vw)', maxHeight: '70vh', overflowY: 'auto', zIndex: 3000 }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
|
<span className="text-sm font-semibold text-secondary-800">Sortieren</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>}
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
{sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>}
|
||||||
|
{sortState.conditions.map((cond, idx) => (
|
||||||
|
<div key={cond.id} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
||||||
|
<select value={cond.field} onChange={(e) => updateCondition(cond.id, { field: e.target.value })} className="flex-1 min-w-0 px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
|
||||||
|
{Object.entries(groupedFields).map(([group, fields]) => (
|
||||||
|
<optgroup key={group} label={group}>{fields.map((f) => (<option key={f.key} value={f.key}>{f.label}</option>))}</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => updateCondition(cond.id, { direction: cond.direction === 'asc' ? 'desc' : 'asc' })} className="p-1.5 rounded border border-secondary-200 hover:bg-secondary-100 text-secondary-600" title={cond.direction === 'asc' ? 'Aufsteigend' : 'Absteigend'}>
|
||||||
|
{cond.direction === 'asc' ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeCondition(cond.id)} className="p-1 rounded hover:bg-red-50 text-secondary-400 hover:text-red-600 transition-colors flex-shrink-0"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button>
|
||||||
|
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -452,16 +452,6 @@ export function ContactsListPage() {
|
|||||||
),
|
),
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
// Saved filters
|
|
||||||
{
|
|
||||||
id: 'saved-filters',
|
|
||||||
plugin: 'contacts',
|
|
||||||
label: 'Gespeicherte Filter',
|
|
||||||
type: 'button' as const,
|
|
||||||
group: 'actions',
|
|
||||||
icon: <Bookmark className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
||||||
onClick: () => setSavedFiltersOpen(true),
|
|
||||||
},
|
|
||||||
// Print — icon only, right side
|
// Print — icon only, right side
|
||||||
...(canAccess('contacts:read') ? [{
|
...(canAccess('contacts:read') ? [{
|
||||||
id: 'print',
|
id: 'print',
|
||||||
|
|||||||
+53
-32
@@ -18,7 +18,10 @@ import { MailDetail } from '@/components/mail/MailDetail';
|
|||||||
import { MailComposeForm, type ComposeMode } from '@/components/mail/MailComposeForm';
|
import { MailComposeForm, type ComposeMode } from '@/components/mail/MailComposeForm';
|
||||||
import { useWindowStore } from '@/store/windowStore';
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
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 type { Tag } from '@/api/tags';
|
||||||
import { useSavedFilters } from '@/api/savedFilters';
|
import { useSavedFilters } from '@/api/savedFilters';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +90,9 @@ export function MailPage() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
||||||
const { data: savedFilters } = useSavedFilters('mail');
|
const { data: savedFilters } = useSavedFilters('mail');
|
||||||
|
const [mailFilterState, setMailFilterState] = useState<MailFilterState>(emptyMailFilterState);
|
||||||
|
const [mailSortState, setMailSortState] = useState<MailSortState>(emptyMailSortState);
|
||||||
|
const [mailGroupState, setMailGroupState] = useState<MailGroupState>(emptyMailGroupState);
|
||||||
|
|
||||||
// Ref to track the current folder ID for async callbacks (prevents race conditions)
|
// Ref to track the current folder ID for async callbacks (prevents race conditions)
|
||||||
const selectedFolderIdRef = useRef<string | null>(null);
|
const selectedFolderIdRef = useRef<string | null>(null);
|
||||||
@@ -594,44 +600,59 @@ export function MailPage() {
|
|||||||
onSearch: handleSearch,
|
onSearch: handleSearch,
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
// Filter dropdown — sort + saved filters (like Contacts)
|
// FilterPanel — Multi-condition filter (like Contacts)
|
||||||
{
|
{
|
||||||
id: 'filter-sort',
|
id: 'filter-panel',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Sortieren',
|
label: 'Filter',
|
||||||
type: 'dropdown' as const,
|
type: 'custom' as const,
|
||||||
group: 'filter',
|
group: 'filter',
|
||||||
icon: <ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />,
|
customComponent: (
|
||||||
menuWidth: '200px',
|
<MailFilterPanel
|
||||||
menuOptions: [
|
filters={mailFilterState}
|
||||||
{ value: 'date-desc', label: 'Datum ↓ (neueste zuerst)', active: sortBy === 'date' && sortOrder === 'desc', onClick: () => { setSortBy('date'); setSortOrder('desc'); } },
|
onFiltersChange={setMailFilterState}
|
||||||
{ value: 'date-asc', label: 'Datum ↑ (älteste zuerst)', active: sortBy === 'date' && sortOrder === 'asc', onClick: () => { setSortBy('date'); setSortOrder('asc'); } },
|
savedFilters={(savedFilters || []).map((f: any) => ({ id: f.id, name: f.name, filterState: f.filter_criteria || emptyMailFilterState }))}
|
||||||
{ value: 'from-asc', label: 'Absender A-Z', active: sortBy === 'from' && sortOrder === 'asc', onClick: () => { setSortBy('from'); setSortOrder('asc'); } },
|
onSaveFilter={(name, state) => {
|
||||||
{ value: 'from-desc', label: 'Absender Z-A', active: sortBy === 'from' && sortOrder === 'desc', onClick: () => { setSortBy('from'); setSortOrder('desc'); } },
|
// TODO: save via API
|
||||||
{ value: 'subject-asc', label: 'Betreff A-Z', active: sortBy === 'subject' && sortOrder === 'asc', onClick: () => { setSortBy('subject'); setSortOrder('asc'); } },
|
console.log('Save filter', name, state);
|
||||||
{ value: 'subject-desc', label: 'Betreff Z-A', active: sortBy === 'subject' && sortOrder === 'desc', onClick: () => { setSortBy('subject'); setSortOrder('desc'); } },
|
}}
|
||||||
],
|
onLoadFilter={(state) => setMailFilterState(state)}
|
||||||
|
onDeleteFilter={(id) => {
|
||||||
|
// TODO: delete via API
|
||||||
|
console.log('Delete filter', id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
|
// SortPanel — Multi-field sort (like Contacts)
|
||||||
{
|
{
|
||||||
id: 'filter-saved',
|
id: 'sort-panel',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Gespeicherte Filter',
|
label: 'Sortieren',
|
||||||
type: 'dropdown' as const,
|
type: 'custom' as const,
|
||||||
group: 'filter',
|
group: 'sort',
|
||||||
icon: <Bookmark className="w-3.5 h-3.5" strokeWidth={2} />,
|
customComponent: (
|
||||||
menuWidth: '220px',
|
<MailSortPanel
|
||||||
menuOptions: (savedFilters || []).length > 0
|
sortState={mailSortState}
|
||||||
? (savedFilters || []).map((f: any) => ({
|
onSortChange={setMailSortState}
|
||||||
value: f.id,
|
/>
|
||||||
label: f.name,
|
),
|
||||||
onClick: () => {
|
onClick: () => {},
|
||||||
if (f.filter_criteria?.search !== undefined) handleSearch(f.filter_criteria.search);
|
},
|
||||||
if (f.filter_criteria?.sortBy) setSortBy(f.filter_criteria.sortBy);
|
// GroupPanel — Multi-field grouping (like Contacts)
|
||||||
if (f.filter_criteria?.sortOrder) setSortOrder(f.filter_criteria.sortOrder);
|
{
|
||||||
},
|
id: 'group-panel',
|
||||||
}))
|
plugin: 'mail',
|
||||||
: [{ value: 'none', label: 'Keine gespeicherten Filter', disabled: true, onClick: () => {} }],
|
label: 'Gruppierung',
|
||||||
|
type: 'custom' as const,
|
||||||
|
group: 'group',
|
||||||
|
customComponent: (
|
||||||
|
<MailGroupPanel
|
||||||
|
groupState={mailGroupState}
|
||||||
|
onGroupChange={setMailGroupState}
|
||||||
|
/>
|
||||||
|
),
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user