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:
@@ -25,6 +25,19 @@ import {
|
|||||||
|
|
||||||
// ==================== E-MAIL LIST ====================
|
// ==================== E-MAIL LIST ====================
|
||||||
|
|
||||||
|
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
|
||||||
|
function parseBoolParam(v: unknown): boolean | undefined {
|
||||||
|
if (v === 'true') return true;
|
||||||
|
if (v === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateParam(v: unknown): Date | undefined {
|
||||||
|
if (typeof v !== 'string' || !v.trim()) return undefined;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -42,6 +55,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
includeBody: false,
|
includeBody: false,
|
||||||
|
search: typeof req.query.search === 'string' ? req.query.search : undefined,
|
||||||
|
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
|
||||||
|
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
|
||||||
|
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
|
||||||
|
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
|
||||||
|
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
|
||||||
|
hasAttachments: parseBoolParam(req.query.hasAttachments),
|
||||||
|
isRead: parseBoolParam(req.query.isRead),
|
||||||
|
isStarred: parseBoolParam(req.query.isStarred),
|
||||||
|
receivedFrom: parseDateParam(req.query.receivedFrom),
|
||||||
|
receivedTo: parseDateParam(req.query.receivedTo),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ export interface EmailListOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
includeBody?: boolean;
|
includeBody?: boolean;
|
||||||
|
// Suche / Filter (alle AND-verknüpft)
|
||||||
|
search?: string; // Volltextsuche über subject + from + body
|
||||||
|
fromFilter?: string; // Absender enthält
|
||||||
|
toFilter?: string; // Empfänger enthält
|
||||||
|
subjectFilter?: string; // Subject enthält
|
||||||
|
bodyFilter?: string; // Body enthält (text/html)
|
||||||
|
attachmentNameFilter?: string; // Anhang-Dateiname enthält
|
||||||
|
hasAttachments?: boolean; // Nur mit/ohne Anhang
|
||||||
|
isRead?: boolean; // Gelesen-Status
|
||||||
|
isStarred?: boolean; // Markiert-Status
|
||||||
|
receivedFrom?: Date; // Empfangen ab
|
||||||
|
receivedTo?: Date; // Empfangen bis
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SYNC FUNCTIONS ====================
|
// ==================== SYNC FUNCTIONS ====================
|
||||||
@@ -273,6 +285,59 @@ export async function getCachedEmails(
|
|||||||
where.folder = EmailFolder.INBOX;
|
where.folder = EmailFolder.INBOX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Such-/Filter-Parameter =====
|
||||||
|
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
|
||||||
|
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
|
||||||
|
if (options.search && options.search.trim()) {
|
||||||
|
const q = options.search.trim();
|
||||||
|
where.OR = [
|
||||||
|
{ subject: { contains: q } },
|
||||||
|
{ fromAddress: { contains: q } },
|
||||||
|
{ fromName: { contains: q } },
|
||||||
|
{ textBody: { contains: q } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
|
||||||
|
if (options.fromFilter?.trim()) {
|
||||||
|
const q = options.fromFilter.trim();
|
||||||
|
// Treffer in fromAddress ODER fromName – für den Nutzer ist „Von" beides
|
||||||
|
where.AND = [
|
||||||
|
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
||||||
|
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (options.toFilter?.trim()) {
|
||||||
|
where.toAddresses = { contains: options.toFilter.trim() };
|
||||||
|
}
|
||||||
|
if (options.subjectFilter?.trim()) {
|
||||||
|
where.subject = { contains: options.subjectFilter.trim() };
|
||||||
|
}
|
||||||
|
if (options.bodyFilter?.trim()) {
|
||||||
|
const q = options.bodyFilter.trim();
|
||||||
|
where.AND = [
|
||||||
|
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
||||||
|
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (options.attachmentNameFilter?.trim()) {
|
||||||
|
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
|
||||||
|
}
|
||||||
|
if (typeof options.hasAttachments === 'boolean') {
|
||||||
|
where.hasAttachments = options.hasAttachments;
|
||||||
|
}
|
||||||
|
if (typeof options.isRead === 'boolean') {
|
||||||
|
where.isRead = options.isRead;
|
||||||
|
}
|
||||||
|
if (typeof options.isStarred === 'boolean') {
|
||||||
|
where.isStarred = options.isStarred;
|
||||||
|
}
|
||||||
|
if (options.receivedFrom || options.receivedTo) {
|
||||||
|
where.receivedAt = {};
|
||||||
|
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
|
||||||
|
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
|
||||||
|
}
|
||||||
|
|
||||||
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
|
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
|
||||||
const select: Prisma.CachedEmailSelect = {
|
const select: Prisma.CachedEmailSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)**
|
||||||
|
- Suchleiste über der Email-Liste – durchsucht parallel Subject,
|
||||||
|
From-Address/Name und Body.
|
||||||
|
- Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit
|
||||||
|
Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis,
|
||||||
|
Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status.
|
||||||
|
Alle Filter werden im Backend mit UND verknüpft.
|
||||||
|
- „Alle zurücksetzen"-Button räumt komplett auf.
|
||||||
|
- Backend: `GET /api/customers/:id/emails` nimmt die Filter als
|
||||||
|
Query-Parameter entgegen, `getCachedEmails` übersetzt sie in eine
|
||||||
|
Prisma `where`-Klausel.
|
||||||
|
- **Bewusst nicht gebaut**: voller AND/OR-Builder mit Plus-Button und
|
||||||
|
Bool-Verschachtelung – Trade-off-Diskussion mit User: reale
|
||||||
|
Use-Cases sind quasi immer AND, UI-Komplexität verschachtelter
|
||||||
|
Bool-Builder bringt mehr Bedienprobleme als Mehrwert.
|
||||||
|
|
||||||
- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
|
- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
|
||||||
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
|
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
|
||||||
(Tooltip erklärt: „ersetzt die Forwards am Provider durch
|
(Tooltip erklärt: „ersetzt die Forwards am Provider durch
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
|
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
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 { useAuth } from '../../context/AuthContext';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
@@ -26,6 +26,67 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
const [showCompose, setShowCompose] = useState(false);
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
const [showAssign, setShowAssign] = useState(false);
|
const [showAssign, setShowAssign] = useState(false);
|
||||||
const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null);
|
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 queryClient = useQueryClient();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
@@ -50,11 +111,12 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
|
|
||||||
// E-Mails laden (nur für INBOX und SENT)
|
// E-Mails laden (nur für INBOX und SENT)
|
||||||
const { data: emailsData, isLoading: emailsLoading, refetch: refetchEmails } = useQuery({
|
const { data: emailsData, isLoading: emailsLoading, refetch: refetchEmails } = useQuery({
|
||||||
queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder],
|
queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder, filterParams],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
cachedEmailApi.getForCustomer(customerId, {
|
cachedEmailApi.getForCustomer(customerId, {
|
||||||
accountId: selectedAccountId || undefined,
|
accountId: selectedAccountId || undefined,
|
||||||
folder: selectedFolder as 'INBOX' | 'SENT',
|
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||||
|
...filterParams,
|
||||||
}),
|
}),
|
||||||
enabled: !!selectedAccountId && selectedFolder !== 'TRASH',
|
enabled: !!selectedAccountId && selectedFolder !== 'TRASH',
|
||||||
});
|
});
|
||||||
@@ -302,7 +364,144 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Email List */}
|
{/* 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' ? (
|
{selectedFolder === 'TRASH' ? (
|
||||||
<TrashEmailList
|
<TrashEmailList
|
||||||
emails={trashEmails}
|
emails={trashEmails}
|
||||||
@@ -343,6 +542,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
accountId={selectedAccountId}
|
accountId={selectedAccountId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Detail */}
|
{/* Email Detail */}
|
||||||
|
|||||||
@@ -463,9 +463,28 @@ export const stressfreiEmailApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cached Email API (E-Mail-Client)
|
// 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 = {
|
export const cachedEmailApi = {
|
||||||
// E-Mails für Kunden abrufen
|
// 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 });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user