feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, type SearchHit } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
const PAGE_SIZE = 25;
export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [query, setQuery] = useState("");
const [fromFilter, setFromFilter] = useState("");
const [toFilter, setToFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
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 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,
page: p,
page_size: PAGE_SIZE,
});
setResults(res.hits || []);
setTotal(res.total);
setPage(p);
setSearched(true);
} catch {
setResults([]);
setTotal(0);
} finally {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo]
);
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
useEffect(() => {
if (!user) return;
setSearching(true);
searchEmails({ 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));
}, [user]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doSearch(1);
}
const totalPages = Math.ceil(total / PAGE_SIZE);
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-7xl px-4 py-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Volltextsuche..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
aria-label="Suchbegriff"
/>
<Button type="submit" disabled={searching}>
{searching ? "Suche..." : "Suchen"}
</Button>
</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>
</form>
<div className="mt-6">
{searching ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : searched && results.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine E-Mails gefunden.
</CardContent>
</Card>
) : results.length > 0 ? (
<>
<div className="mb-2 text-sm text-muted-foreground">
{query || fromFilter || toFilter || dateFrom || dateTo
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32">Datum</TableHead>
<TableHead className="w-56">Von</TableHead>
<TableHead>Betreff</TableHead>
<TableHead className="w-48">An</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 className="whitespace-nowrap text-sm text-muted-foreground">
{hit.date
? new Date(hit.date).toLocaleDateString("de-DE")
: "-"}
</TableCell>
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</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>
)}
</>
) : null}
</div>
</main>
</div>
);
}