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:
sysops
2026-06-22 11:29:43 +02:00
parent ce197a3ab7
commit a55faf74b1
33 changed files with 4000 additions and 2764 deletions
+136 -715
View File
@@ -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>