feat: Mail FilterPanel, SortPanel, GroupPanel like Contacts + remove saved-filters button from Contacts and Mail
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user