chore: Frontend-Seiten in Komponenten/Hooks aufteilen
admin/page.tsx (1019→446), search/page.tsx (871→304), imap/page.tsx (738→142), settings/page.tsx (641→56). Reine Umstrukturierung, keine Verhaltensänderung, Build verifiziert.
This commit is contained in:
+136
-715
@@ -1,86 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type SearchMatchField, type UploadJob, type SavedSearch } from "@/lib/api";
|
||||
import { sanitizeSnippet } from "@/lib/sanitize";
|
||||
import { useSearch, DEFAULT_PAGE_SIZE } from "@/hooks/useSearch";
|
||||
import { useSavedSearches } from "@/hooks/useSavedSearches";
|
||||
import { useMailUpload } from "@/hooks/useMailUpload";
|
||||
import { exportMailsZIP, exportEDiscovery } from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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 DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
|
||||
subject: "📨 Subject",
|
||||
body: "✉️ Body",
|
||||
attachment_text: "📄 PDF-Anhang",
|
||||
attachment_names: "📎 Dateiname",
|
||||
from_addr: "👤 Absender",
|
||||
to_addr: "📧 Empfänger",
|
||||
};
|
||||
|
||||
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
|
||||
{MATCH_FIELD_LABEL[field]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SnippetLine({ hit }: { hit: SearchHit }) {
|
||||
if (!hit.snippet) return null;
|
||||
return (
|
||||
<div className="mt-1 flex items-start gap-2 text-xs text-muted-foreground font-normal">
|
||||
{hit.match_field && <MatchSourceBadge field={hit.match_field} />}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate [&_b]:font-semibold [&_b]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(hit.snippet) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { SearchFilterBar } from "@/components/search/SearchFilterBar";
|
||||
import { SearchResultsTable } from "@/components/search/SearchResultsTable";
|
||||
import {
|
||||
ExportZipDialog,
|
||||
EDiscoveryDialog,
|
||||
UploadDialog,
|
||||
} from "@/components/search/ExportDialogs";
|
||||
|
||||
export default function SearchPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -88,19 +25,34 @@ export default function SearchPage() {
|
||||
|
||||
const pageSize = user?.list_page_size ?? DEFAULT_PAGE_SIZE;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [fromFilter, setFromFilter] = useState("");
|
||||
const [toFilter, setToFilter] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sort, setSort] = useState("date_desc");
|
||||
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const [results, setResults] = useState<SearchHit[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const search = useSearch(user, pageSize);
|
||||
const {
|
||||
query,
|
||||
setQuery,
|
||||
fromFilter,
|
||||
setFromFilter,
|
||||
toFilter,
|
||||
setToFilter,
|
||||
dateFrom,
|
||||
setDateFrom,
|
||||
dateTo,
|
||||
setDateTo,
|
||||
sort,
|
||||
setSort,
|
||||
hasAttachment,
|
||||
setHasAttachment,
|
||||
results,
|
||||
setResults,
|
||||
total,
|
||||
setTotal,
|
||||
page,
|
||||
setPage,
|
||||
searching,
|
||||
setSearching,
|
||||
searched,
|
||||
setSearched,
|
||||
doSearch,
|
||||
} = search;
|
||||
|
||||
// Selection state
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -111,56 +63,35 @@ export default function SearchPage() {
|
||||
const [ediscoveryCaseName, setEdiscoveryCaseName] = useState("");
|
||||
const [ediscoveryLoading, setEdiscoveryLoading] = useState(false);
|
||||
|
||||
// Upload state
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadDragging, setUploadDragging] = useState(false);
|
||||
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const upload = useMailUpload(() => doSearch(1));
|
||||
|
||||
// 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);
|
||||
const saved = useSavedSearches({
|
||||
user,
|
||||
pageSize,
|
||||
query,
|
||||
fromFilter,
|
||||
toFilter,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
hasAttachment,
|
||||
setQuery,
|
||||
setFromFilter,
|
||||
setToFilter,
|
||||
setDateFrom,
|
||||
setDateTo,
|
||||
setHasAttachment,
|
||||
setResults,
|
||||
setTotal,
|
||||
setPage,
|
||||
setSearching,
|
||||
setSearched,
|
||||
});
|
||||
|
||||
// Clear selection when results change
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
}, [results]);
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (p: number) => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await searchEmails({
|
||||
q: query || undefined,
|
||||
from: fromFilter || undefined,
|
||||
to: toFilter || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
sort: sort !== "date_desc" ? sort : undefined,
|
||||
has_attachment: hasAttachment,
|
||||
page: p,
|
||||
page_size: pageSize,
|
||||
});
|
||||
setResults(res.hits || []);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setSearched(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize]
|
||||
);
|
||||
|
||||
// Superadmin has no mail access — redirect to admin dashboard
|
||||
useEffect(() => {
|
||||
if (user?.role === "superadmin") {
|
||||
@@ -168,24 +99,6 @@ export default function SearchPage() {
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
||||
useEffect(() => {
|
||||
if (!user || user.role === "superadmin") return;
|
||||
setSearching(true);
|
||||
searchEmails({ page: 1, page_size: pageSize })
|
||||
.then((res) => {
|
||||
setResults(res.hits || []);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
setSearched(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setSearching(false));
|
||||
}, [user, pageSize]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
doSearch(1);
|
||||
@@ -235,139 +148,8 @@ export default function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadFiles(files: FileList | File[]) {
|
||||
const allowed = Array.from(files).filter((f) => {
|
||||
const n = f.name.toLowerCase();
|
||||
return n.endsWith(".eml") || n.endsWith(".mbox");
|
||||
});
|
||||
if (allowed.length === 0) {
|
||||
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
|
||||
return;
|
||||
}
|
||||
setUploadError("");
|
||||
setUploadJob(null);
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const { job_id } = await uploadMailFilesUser(allowed);
|
||||
uploadPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getUploadProgressUser(job_id);
|
||||
setUploadJob(job);
|
||||
if (job.status !== "running") {
|
||||
clearInterval(uploadPollRef.current!);
|
||||
uploadPollRef.current = null;
|
||||
setUploadLoading(false);
|
||||
// Refresh search results after successful import
|
||||
if (job.status === "done") doSearch(1);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(uploadPollRef.current!);
|
||||
uploadPollRef.current = null;
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUploadClose() {
|
||||
if (uploadPollRef.current) {
|
||||
clearInterval(uploadPollRef.current);
|
||||
uploadPollRef.current = null;
|
||||
}
|
||||
setUploadOpen(false);
|
||||
setUploadJob(null);
|
||||
setUploadError("");
|
||||
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: pageSize,
|
||||
})
|
||||
.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 / pageSize);
|
||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -384,183 +166,38 @@ export default function SearchPage() {
|
||||
<div className="flex gap-6">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Volltextsuche..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full flex-1 sm:w-auto"
|
||||
aria-label="Suchbegriff"
|
||||
/>
|
||||
<Button type="submit" disabled={searching} className="flex-1 sm:flex-none">
|
||||
{searching ? "Suche..." : "Suchen"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)} className="flex-1 sm:flex-none">
|
||||
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">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="from-filter" className="text-xs">
|
||||
Von (Absender)
|
||||
</Label>
|
||||
<Input
|
||||
id="from-filter"
|
||||
placeholder="absender@example.com"
|
||||
value={fromFilter}
|
||||
onChange={(e) => setFromFilter(e.target.value)}
|
||||
aria-label="Absender filtern"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="to-filter" className="text-xs">
|
||||
An (Empfänger)
|
||||
</Label>
|
||||
<Input
|
||||
id="to-filter"
|
||||
placeholder="empfaenger@example.com"
|
||||
value={toFilter}
|
||||
onChange={(e) => setToFilter(e.target.value)}
|
||||
aria-label="Empfänger filtern"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="date-from" className="text-xs">
|
||||
Datum von
|
||||
</Label>
|
||||
<Input
|
||||
id="date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
aria-label="Datum von"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="date-to" className="text-xs">
|
||||
Datum bis
|
||||
</Label>
|
||||
<Input
|
||||
id="date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
aria-label="Datum bis"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
|
||||
<Select value={sort} onValueChange={setSort}>
|
||||
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">Datum (neu → alt)</SelectItem>
|
||||
<SelectItem value="date_asc">Datum (alt → neu)</SelectItem>
|
||||
<SelectItem value="relevance">Relevanz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="attach-toggle"
|
||||
checked={hasAttachment === true}
|
||||
onCheckedChange={(checked) =>
|
||||
setHasAttachment(checked ? true : undefined)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
|
||||
Nur mit Anhang
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SearchFilterBar
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
fromFilter={fromFilter}
|
||||
setFromFilter={setFromFilter}
|
||||
toFilter={toFilter}
|
||||
setToFilter={setToFilter}
|
||||
dateFrom={dateFrom}
|
||||
setDateFrom={setDateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateTo={setDateTo}
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
hasAttachment={hasAttachment}
|
||||
setHasAttachment={setHasAttachment}
|
||||
searching={searching}
|
||||
onSubmit={handleSubmit}
|
||||
onOpenUpload={() => upload.setUploadOpen(true)}
|
||||
hasActiveSearch={hasActiveSearch}
|
||||
savePopoverOpen={saved.savePopoverOpen}
|
||||
setSavePopoverOpen={saved.setSavePopoverOpen}
|
||||
saveName={saved.saveName}
|
||||
setSaveName={saved.setSaveName}
|
||||
saving={saved.saving}
|
||||
onSaveSearch={saved.handleSaveSearch}
|
||||
savedListOpen={saved.savedListOpen}
|
||||
setSavedListOpen={saved.setSavedListOpen}
|
||||
savedLoading={saved.savedLoading}
|
||||
savedSearches={saved.savedSearches}
|
||||
onApplySavedSearch={saved.handleApplySavedSearch}
|
||||
onDeleteSavedSearch={saved.handleDeleteSavedSearch}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
{searching ? (
|
||||
@@ -598,272 +235,56 @@ export default function SearchPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelected(new Set(results.map((h) => h.id)));
|
||||
else setSelected(new Set());
|
||||
}}
|
||||
aria-label="Alle auswählen"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-28 sm:w-32">Datum</TableHead>
|
||||
<TableHead className="hidden w-56 md:table-cell">Von</TableHead>
|
||||
<TableHead>Betreff</TableHead>
|
||||
<TableHead className="hidden w-48 lg:table-cell">An</TableHead>
|
||||
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
|
||||
<TableHead className="hidden w-20 text-right sm:table-cell">Größe</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.map((hit) => (
|
||||
<TableRow
|
||||
key={hit.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/mail/${hit.id}`)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/mail/${hit.id}`);
|
||||
}}
|
||||
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected.has(hit.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(hit.id);
|
||||
else next.delete(hit.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="Mail auswählen"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{hit.date
|
||||
? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" })
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-[14rem] truncate text-sm md:table-cell">{hit.from || "-"}</TableCell>
|
||||
<TableCell className="max-w-[60vw] font-medium sm:max-w-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{hit.subject || "(kein Betreff)"}</span>
|
||||
{hit.thread_size && hit.thread_size > 1 && (
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
|
||||
{hit.thread_size}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Absender nur auf Mobile, da Spalte "Von" dort ausgeblendet ist */}
|
||||
{hit.from && (
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground md:hidden">
|
||||
{hit.from}
|
||||
</div>
|
||||
)}
|
||||
<SnippetLine hit={hit} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-[12rem] truncate text-sm text-muted-foreground lg:table-cell">{hit.to || "-"}</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hit.has_attachments ? "📎" : ""}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs text-muted-foreground whitespace-nowrap sm:table-cell">
|
||||
{hit.size ? formatBytes(hit.size) : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => doSearch(page - 1)}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => doSearch(page + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<SearchResultsTable
|
||||
results={results}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
total={total}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => doSearch(p)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>{/* end flex-1 */}
|
||||
</div>{/* end flex gap-6 */}
|
||||
|
||||
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-Mails exportieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Switch
|
||||
id="attachments"
|
||||
checked={exportAttachments}
|
||||
onCheckedChange={setExportAttachments}
|
||||
/>
|
||||
<Label htmlFor="attachments">Anhänge einschließen</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExportOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleExportZIP} disabled={exporting}>
|
||||
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* eDiscovery Export Dialog */}
|
||||
<Dialog open={ediscoveryOpen} onOpenChange={setEdiscoveryOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>eDiscovery Export</DialogTitle>
|
||||
<DialogDescription>
|
||||
Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="case-name">Case-Name (optional)</Label>
|
||||
<input
|
||||
id="case-name"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="z.B. Ermittlung-2026-Q1"
|
||||
value={ediscoveryCaseName}
|
||||
onChange={(e) => setEdiscoveryCaseName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground space-y-1">
|
||||
<div><span className="font-medium">Aktive Filter:</span></div>
|
||||
{query && <div>Suche: <span className="font-mono">{query}</span></div>}
|
||||
{fromFilter && <div>Von: <span className="font-mono">{fromFilter}</span></div>}
|
||||
{toFilter && <div>An: <span className="font-mono">{toFilter}</span></div>}
|
||||
{dateFrom && <div>Von Datum: {dateFrom}</div>}
|
||||
{dateTo && <div>Bis Datum: {dateTo}</div>}
|
||||
{!query && !fromFilter && !toFilter && !dateFrom && !dateTo && (
|
||||
<div className="italic">Keine Filter — alle archivierten Mails werden exportiert</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEdiscoveryOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleEDiscoveryExport} disabled={ediscoveryLoading}>
|
||||
{ediscoveryLoading ? "Wird exportiert..." : "ZIP herunterladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ExportZipDialog
|
||||
open={exportOpen}
|
||||
onOpenChange={setExportOpen}
|
||||
selectedCount={selected.size}
|
||||
exportAttachments={exportAttachments}
|
||||
setExportAttachments={setExportAttachments}
|
||||
exporting={exporting}
|
||||
onExport={handleExportZIP}
|
||||
/>
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog open={uploadOpen} onOpenChange={(open) => { if (!open) handleUploadClose(); else setUploadOpen(true); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-Mails importieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
EML- oder MBOX-Dateien in das Archiv hochladen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EDiscoveryDialog
|
||||
open={ediscoveryOpen}
|
||||
onOpenChange={setEdiscoveryOpen}
|
||||
caseName={ediscoveryCaseName}
|
||||
setCaseName={setEdiscoveryCaseName}
|
||||
loading={ediscoveryLoading}
|
||||
onExport={handleEDiscoveryExport}
|
||||
query={query}
|
||||
fromFilter={fromFilter}
|
||||
toFilter={toFilter}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
/>
|
||||
|
||||
{!uploadJob && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
|
||||
onDragLeave={() => setUploadDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setUploadDragging(false);
|
||||
if (e.dataTransfer.files.length > 0) handleUploadFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Dateien hierher ziehen oder auswählen
|
||||
</p>
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".eml,.mbox"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) handleUploadFiles(e.target.files); }}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<span>Dateien auswählen</span>
|
||||
</Button>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-muted-foreground">.eml · .mbox</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="text-sm text-destructive">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{uploadJob && (
|
||||
<div className="space-y-3">
|
||||
<Progress
|
||||
value={uploadJob.total > 0 ? Math.round(((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100) : 0}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-green-600">{uploadJob.imported}</div>
|
||||
<div className="text-xs text-muted-foreground">Importiert</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-yellow-600">{uploadJob.skipped}</div>
|
||||
<div className="text-xs text-muted-foreground">Duplikate</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-red-600">{uploadJob.errors}</div>
|
||||
<div className="text-xs text-muted-foreground">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
{uploadJob.status === "running" && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Verarbeite {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} …
|
||||
</p>
|
||||
)}
|
||||
{uploadJob.status === "done" && (
|
||||
<p className="text-xs text-center text-green-600 font-medium">Abgeschlossen</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleUploadClose} disabled={uploadLoading && uploadJob?.status === "running"}>
|
||||
{uploadJob?.status === "done" ? "Schließen" : "Abbrechen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<UploadDialog
|
||||
open={upload.uploadOpen}
|
||||
onOpenChange={(open) => { if (!open) upload.handleUploadClose(); else upload.setUploadOpen(true); }}
|
||||
dragging={upload.uploadDragging}
|
||||
setDragging={upload.setUploadDragging}
|
||||
job={upload.uploadJob}
|
||||
error={upload.uploadError}
|
||||
loading={upload.uploadLoading}
|
||||
onUploadFiles={upload.handleUploadFiles}
|
||||
onClose={upload.handleUploadClose}
|
||||
/>
|
||||
</>)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user