feat(email): Suchleiste + erweiterte Filter im Email-Postfach

Variante B aus der Trade-off-Diskussion: Suchleiste über der Email-Liste
plus eine ausklappbare Box mit Detail-Filtern, alle AND-verknüpft.

Backend:
- EmailListOptions um search + 9 Detail-Filter erweitert (fromFilter,
  toFilter, subjectFilter, bodyFilter, attachmentNameFilter,
  hasAttachments, isRead, isStarred, receivedFrom, receivedTo)
- getCachedEmails baut die where-Klausel:
  * `search` → OR über Subject/From-Address/From-Name/Body (Volltext-
    Quicksearch)
  * Feldspezifische Filter werden AND-verknüpft an die where gehängt;
    From-/Body-Filter intern als kleine OR-Subqueries (Match in
    Adresse ODER Name; Match in textBody ODER htmlBody)
- Controller-Parser akzeptiert die Filter als Query-Parameter
  (parseBoolParam/parseDateParam tolerieren leere/invalide Werte)

Frontend:
- Suchleiste mit X-Button zum Leeren + Filter-Toggle mit Badge (zeigt
  Anzahl aktiver Filter)
- Ausklappbare Filter-Box: Von, An, Betreff, Inhalt, Datum von/bis,
  Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status
- Filter-State fließt via useMemo + queryKey in den useQuery → React
  Query macht automatisch ein Re-Fetch bei jeder Änderung
- "Alle zurücksetzen"-Button räumt komplett auf
- Nicht für TRASH-Folder eingeblendet (eigener Pfad ohne Filter-API)

Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung. Reale Such-Use-Cases im Email-Kontext sind
quasi immer AND-verknüpft; Bool-Builder bringt mehr Bedienprobleme
als Mehrwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 14:31:43 +02:00
parent 51eb12b414
commit 185b38dc55
5 changed files with 330 additions and 6 deletions
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import Button from '../ui/Button';
@@ -26,6 +26,67 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const [showCompose, setShowCompose] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null);
// Such- und Filterzustand. Alle Filter sind AND-verknüpft im Backend.
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [filterFrom, setFilterFrom] = useState('');
const [filterTo, setFilterTo] = useState('');
const [filterSubject, setFilterSubject] = useState('');
const [filterBody, setFilterBody] = useState('');
const [filterAttachmentName, setFilterAttachmentName] = useState('');
const [filterHasAttachments, setFilterHasAttachments] = useState<'any' | 'yes' | 'no'>('any');
const [filterReadStatus, setFilterReadStatus] = useState<'any' | 'unread' | 'read'>('any');
const [filterStarred, setFilterStarred] = useState<'any' | 'starred'>('any');
const [filterDateFrom, setFilterDateFrom] = useState('');
const [filterDateTo, setFilterDateTo] = useState('');
// Filter-Parameter (memoized) fließen in queryKey + queryFn.
const filterParams: EmailFilterParams = useMemo(() => {
const p: EmailFilterParams = {};
if (searchQuery.trim()) p.search = searchQuery.trim();
if (filterFrom.trim()) p.fromFilter = filterFrom.trim();
if (filterTo.trim()) p.toFilter = filterTo.trim();
if (filterSubject.trim()) p.subjectFilter = filterSubject.trim();
if (filterBody.trim()) p.bodyFilter = filterBody.trim();
if (filterAttachmentName.trim()) p.attachmentNameFilter = filterAttachmentName.trim();
if (filterHasAttachments === 'yes') p.hasAttachments = true;
if (filterHasAttachments === 'no') p.hasAttachments = false;
if (filterReadStatus === 'read') p.isRead = true;
if (filterReadStatus === 'unread') p.isRead = false;
if (filterStarred === 'starred') p.isStarred = true;
if (filterDateFrom) p.receivedFrom = new Date(filterDateFrom + 'T00:00:00').toISOString();
if (filterDateTo) p.receivedTo = new Date(filterDateTo + 'T23:59:59').toISOString();
return p;
}, [
searchQuery,
filterFrom,
filterTo,
filterSubject,
filterBody,
filterAttachmentName,
filterHasAttachments,
filterReadStatus,
filterStarred,
filterDateFrom,
filterDateTo,
]);
const activeFilterCount = Object.keys(filterParams).length;
const clearAllFilters = () => {
setSearchQuery('');
setFilterFrom('');
setFilterTo('');
setFilterSubject('');
setFilterBody('');
setFilterAttachmentName('');
setFilterHasAttachments('any');
setFilterReadStatus('any');
setFilterStarred('any');
setFilterDateFrom('');
setFilterDateTo('');
};
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
@@ -50,11 +111,12 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
// E-Mails laden (nur für INBOX und SENT)
const { data: emailsData, isLoading: emailsLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder],
queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder, filterParams],
queryFn: () =>
cachedEmailApi.getForCustomer(customerId, {
accountId: selectedAccountId || undefined,
folder: selectedFolder as 'INBOX' | 'SENT',
...filterParams,
}),
enabled: !!selectedAccountId && selectedFolder !== 'TRASH',
});
@@ -302,7 +364,144 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{/* Email List */}
<div className="w-1/3 border-r border-gray-200 overflow-auto">
<div className="w-1/3 border-r border-gray-200 flex flex-col overflow-hidden">
{selectedFolder !== 'TRASH' && selectedAccountId && (
<div className="border-b border-gray-200 bg-gray-50 p-2 space-y-2">
{/* Suchleiste */}
<div className="flex items-center gap-1">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suche in Betreff, Absender, Inhalt…"
className="w-full pl-8 pr-7 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
title="Suche zurücksetzen"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
onClick={() => setShowFilters((v) => !v)}
className={`flex items-center gap-1 px-2 py-1.5 text-sm border rounded ${
showFilters || activeFilterCount > 0
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-300 text-gray-600 hover:bg-gray-50'
}`}
title="Erweiterte Filter ein-/ausblenden"
>
<SlidersHorizontal className="w-4 h-4" />
{activeFilterCount > 0 && (
<span className="text-xs font-semibold">{activeFilterCount}</span>
)}
</button>
</div>
{/* Ausklappbare erweiterte Filter (alle AND-verknüpft) */}
{showFilters && (
<div className="space-y-2 pt-1 border-t border-gray-200">
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
placeholder="Von (Adresse/Name)"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
placeholder="An"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={filterSubject}
onChange={(e) => setFilterSubject(e.target.value)}
placeholder="Betreff enthält"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={filterBody}
onChange={(e) => setFilterBody(e.target.value)}
placeholder="Inhalt enthält"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
title="Empfangen ab"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
title="Empfangen bis"
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={filterAttachmentName}
onChange={(e) => setFilterAttachmentName(e.target.value)}
placeholder="Anhang-Dateiname"
className="col-span-2 px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<select
value={filterHasAttachments}
onChange={(e) => setFilterHasAttachments(e.target.value as 'any' | 'yes' | 'no')}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="any">Anhang: egal</option>
<option value="yes">Mit Anhang</option>
<option value="no">Ohne Anhang</option>
</select>
<select
value={filterReadStatus}
onChange={(e) => setFilterReadStatus(e.target.value as 'any' | 'unread' | 'read')}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="any">Status: egal</option>
<option value="unread">Ungelesen</option>
<option value="read">Gelesen</option>
</select>
<select
value={filterStarred}
onChange={(e) => setFilterStarred(e.target.value as 'any' | 'starred')}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="any">Stern: egal</option>
<option value="starred">Nur markiert</option>
</select>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Filter werden mit UND verknüpft.</span>
{activeFilterCount > 0 && (
<button
onClick={clearAllFilters}
className="text-blue-600 hover:underline"
>
Alle zurücksetzen
</button>
)}
</div>
</div>
)}
</div>
)}
<div className="flex-1 overflow-auto">
{selectedFolder === 'TRASH' ? (
<TrashEmailList
emails={trashEmails}
@@ -343,6 +542,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
accountId={selectedAccountId}
/>
)}
</div>
</div>
{/* Email Detail */}
+20 -1
View File
@@ -463,9 +463,28 @@ export const stressfreiEmailApi = {
};
// Cached Email API (E-Mail-Client)
export interface EmailFilterParams {
accountId?: number;
folder?: 'INBOX' | 'SENT';
limit?: number;
offset?: number;
// Suche / Filter (alle AND-verknüpft)
search?: string;
fromFilter?: string;
toFilter?: string;
subjectFilter?: string;
bodyFilter?: string;
attachmentNameFilter?: string;
hasAttachments?: boolean;
isRead?: boolean;
isStarred?: boolean;
receivedFrom?: string; // ISO date
receivedTo?: string; // ISO date
}
export const cachedEmailApi = {
// E-Mails für Kunden abrufen
getForCustomer: async (customerId: number, options?: { accountId?: number; folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
getForCustomer: async (customerId: number, options?: EmailFilterParams) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
return res.data;
},