feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen
PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden
PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+179
-1
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api";
|
||||
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type UploadJob, type SavedSearch } from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -80,6 +86,14 @@ export default function SearchPage() {
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Saved searches state
|
||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
||||
const [savedLoading, setSavedLoading] = useState(false);
|
||||
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedListOpen, setSavedListOpen] = useState(false);
|
||||
|
||||
// Clear selection when results change
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
@@ -236,6 +250,89 @@ export default function SearchPage() {
|
||||
setUploadLoading(false);
|
||||
}
|
||||
|
||||
// Load saved searches on mount
|
||||
useEffect(() => {
|
||||
if (!user || user.role === "superadmin") return;
|
||||
setSavedLoading(true);
|
||||
listSavedSearches()
|
||||
.then((list) => setSavedSearches(list || []))
|
||||
.catch(() => setSavedSearches([]))
|
||||
.finally(() => setSavedLoading(false));
|
||||
}, [user]);
|
||||
|
||||
const hasActiveSearch = !!(query || fromFilter || toFilter || dateFrom || dateTo || hasAttachment);
|
||||
|
||||
function buildCurrentQuery(): Record<string, string> {
|
||||
const q: Record<string, string> = {};
|
||||
if (query) q.q = query;
|
||||
if (fromFilter) q.from = fromFilter;
|
||||
if (toFilter) q.to = toFilter;
|
||||
if (dateFrom) q.date_from = dateFrom;
|
||||
if (dateTo) q.date_to = dateTo;
|
||||
if (hasAttachment) q.has_attachment = "true";
|
||||
return q;
|
||||
}
|
||||
|
||||
async function handleSaveSearch() {
|
||||
if (!saveName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await createSavedSearch(saveName.trim(), buildCurrentQuery());
|
||||
setSavedSearches((prev) => [saved, ...prev]);
|
||||
setSaveName("");
|
||||
setSavePopoverOpen(false);
|
||||
} catch (e) {
|
||||
alert(`Suche speichern fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplySavedSearch(s: SavedSearch) {
|
||||
setQuery(s.query.q || "");
|
||||
setFromFilter(s.query.from || "");
|
||||
setToFilter(s.query.to || "");
|
||||
setDateFrom(s.query.date_from || "");
|
||||
setDateTo(s.query.date_to || "");
|
||||
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
|
||||
setSavedListOpen(false);
|
||||
// Trigger search after state updates
|
||||
setTimeout(() => {
|
||||
// We need to search with the saved query directly since state isn't updated yet
|
||||
setSearching(true);
|
||||
searchEmails({
|
||||
q: s.query.q || undefined,
|
||||
from: s.query.from || undefined,
|
||||
to: s.query.to || undefined,
|
||||
date_from: s.query.date_from || undefined,
|
||||
date_to: s.query.date_to || undefined,
|
||||
has_attachment: s.query.has_attachment === "true" ? true : undefined,
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
.then((res) => {
|
||||
setResults(res.hits || []);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
setSearched(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setSearching(false));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function handleDeleteSavedSearch(id: number) {
|
||||
try {
|
||||
await deleteSavedSearch(id);
|
||||
setSavedSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (e) {
|
||||
alert(`Loeschen fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||
|
||||
@@ -269,6 +366,87 @@ export default function SearchPage() {
|
||||
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)}>
|
||||
Importieren
|
||||
</Button>
|
||||
{hasActiveSearch && (
|
||||
<Popover open={savePopoverOpen} onOpenChange={setSavePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="icon" title="Suche speichern" aria-label="Suche speichern">
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Suche speichern</p>
|
||||
<Input
|
||||
placeholder="Name der Suche..."
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSaveSearch(); }}
|
||||
aria-label="Name der gespeicherten Suche"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSavePopoverOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveSearch} disabled={saving || !saveName.trim()}>
|
||||
{saving ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Popover open={savedListOpen} onOpenChange={setSavedListOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="icon" title="Gespeicherte Suchen" aria-label="Gespeicherte Suchen">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<p className="text-sm font-medium mb-3">Gespeicherte Suchen</p>
|
||||
{savedLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : savedSearches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
Keine gespeicherten Suchen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{savedSearches.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-sm truncate"
|
||||
onClick={() => handleApplySavedSearch(s)}
|
||||
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap hidden group-hover:inline">
|
||||
{new Date(s.created_at).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteSavedSearch(s.id); }}
|
||||
aria-label={`Suche "${s.name}" loeschen`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -156,3 +156,10 @@ export {
|
||||
generateSelfSignedCert,
|
||||
requestACMECert,
|
||||
} from "./system";
|
||||
|
||||
export type { SavedSearch } from "./saved_searches";
|
||||
export {
|
||||
listSavedSearches,
|
||||
createSavedSearch,
|
||||
deleteSavedSearch,
|
||||
} from "./saved_searches";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { request } from "./core";
|
||||
|
||||
export interface SavedSearch {
|
||||
id: number;
|
||||
name: string;
|
||||
query: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listSavedSearches(): Promise<SavedSearch[]> {
|
||||
return request<SavedSearch[]>("/api/searches/saved");
|
||||
}
|
||||
|
||||
export async function createSavedSearch(
|
||||
name: string,
|
||||
query: Record<string, string>
|
||||
): Promise<SavedSearch> {
|
||||
return request<SavedSearch>("/api/searches/saved", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, query }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSavedSearch(id: number): Promise<void> {
|
||||
await request<Record<string, never>>(`/api/searches/saved/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user