abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
193 lines
8.4 KiB
TypeScript
193 lines
8.4 KiB
TypeScript
// TODO: P2-F15 — Replace hardcoded SORT_FIELDS with shared constant
|
|
/**
|
|
* 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 ?? 0)) 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 ?? 0;
|
|
|
|
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|