Add search and filters to the scan history (Verlauf) view

Task: Filter and search scans in the history view (Task #32).

What changed:
- artifacts/skillguard/src/pages/scan-history.tsx now has a client-side
  search box that matches scan name and description text (case-insensitive),
  plus two multi-select toggle-group filters: Bewertung (verdict:
  pass/review/block) and Quelle (source: zip/file/text).
- Filtering is computed via useMemo over the existing useListScans() result.
- Added a results counter ("X von Y Scans") and a "Filter zurücksetzen"
  button shown when any filter/search is active.
- Added a dedicated empty-filter-result state ("Keine Treffer") with a
  reset action, distinct from the existing "no scans yet" empty state.
- Search input has a clear (X) button.

Notes / deviations:
- No backend changes; description/verdict/source already come from the API.
- Rebuilt lib/api-client-react declarations (tsc -b) because the dist
  *.d.ts in this isolated env were stale and lacked description/relation
  fields (pre-existing issue, documented in memory). tsc on scan-history
  is now clean.
- Verified visually: search bar, verdict and source filters render and the
  list populates from the running API server.

Replit-Task-Id: 67d3a8ce-f780-4b21-97b4-4767ced763c4
This commit is contained in:
amertensreplit 2026-06-11 01:24:29 +00:00
parent 2f7e58670a
commit 487d1f3d3c
2 changed files with 127 additions and 5 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,22 +1,63 @@
import { useMemo, useState } from "react";
import { Link } from "wouter"; import { Link } from "wouter";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useListScans, getListScansQueryKey, useDeleteScan } from "@workspace/api-client-react"; import { useListScans, getListScansQueryKey, useDeleteScan } from "@workspace/api-client-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { VerdictBadge, RelationBadge } from "@/components/ui-helpers"; import { VerdictBadge, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { Search, Trash2, ArrowRight } from "lucide-react"; import { Search, Trash2, ArrowRight, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const VERDICT_OPTIONS = [
{ value: "pass", label: "Freigabe" },
{ value: "review", label: "Manuelle Prüfung" },
{ value: "block", label: "Blockieren" },
] as const;
const SOURCE_OPTIONS = [
{ value: "zip", label: "ZIP" },
{ value: "file", label: "Datei" },
{ value: "text", label: "Text" },
] as const;
export default function ScanHistory() { export default function ScanHistory() {
const { data: scans, isLoading } = useListScans(); const { data: scans, isLoading } = useListScans();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteScan = useDeleteScan(); const deleteScan = useDeleteScan();
const { toast } = useToast(); const { toast } = useToast();
const [query, setQuery] = useState("");
const [verdictFilters, setVerdictFilters] = useState<string[]>([]);
const [sourceFilters, setSourceFilters] = useState<string[]>([]);
const hasActiveFilters = query.trim() !== "" || verdictFilters.length > 0 || sourceFilters.length > 0;
const resetFilters = () => {
setQuery("");
setVerdictFilters([]);
setSourceFilters([]);
};
const filteredScans = useMemo(() => {
if (!scans) return [];
const q = query.trim().toLowerCase();
return scans.filter((scan) => {
if (verdictFilters.length > 0 && !verdictFilters.includes(scan.verdict)) return false;
if (sourceFilters.length > 0 && !sourceFilters.includes(scan.source)) return false;
if (q) {
const haystack = `${scan.name ?? ""} ${scan.description ?? ""}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [scans, query, verdictFilters, sourceFilters]);
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
deleteScan.mutate({ id }, { deleteScan.mutate({ id }, {
onSuccess: () => { onSuccess: () => {
@ -59,8 +100,87 @@ export default function ScanHistory() {
</Button> </Button>
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <>
{scans.map((scan) => ( <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative w-full lg:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Nach Name oder Beschreibung suchen…"
className="pl-9 pr-9"
aria-label="Scans durchsuchen"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Suche löschen"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">Bewertung</span>
<ToggleGroup
type="multiple"
variant="outline"
size="sm"
value={verdictFilters}
onValueChange={setVerdictFilters}
className="justify-start"
>
{VERDICT_OPTIONS.map((opt) => (
<ToggleGroupItem key={opt.value} value={opt.value} aria-label={opt.label}>
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">Quelle</span>
<ToggleGroup
type="multiple"
variant="outline"
size="sm"
value={sourceFilters}
onValueChange={setSourceFilters}
className="justify-start"
>
{SOURCE_OPTIONS.map((opt) => (
<ToggleGroupItem key={opt.value} value={opt.value} aria-label={opt.label}>
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
</div>
{hasActiveFilters && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{filteredScans.length} von {scans.length} Scans</span>
<Button variant="ghost" size="sm" onClick={resetFilters} className="h-auto py-1">
<X className="w-4 h-4 mr-1" /> Filter zurücksetzen
</Button>
</div>
)}
{filteredScans.length === 0 ? (
<Card className="flex flex-col items-center justify-center p-12 text-center">
<Search className="w-12 h-12 text-muted-foreground mb-4 opacity-50" />
<h2 className="text-xl font-bold mb-2">Keine Treffer</h2>
<p className="text-muted-foreground mb-6 max-w-md">Für die aktuellen Filter- und Sucheinstellungen wurden keine Scans gefunden.</p>
<Button variant="outline" onClick={resetFilters}>
<X className="w-4 h-4 mr-1" /> Filter zurücksetzen
</Button>
</Card>
) : (
<div className="space-y-4">
{filteredScans.map((scan) => (
<Card key={scan.id} className="overflow-hidden hover:border-primary/50 transition-colors"> <Card key={scan.id} className="overflow-hidden hover:border-primary/50 transition-colors">
<div className="flex flex-col sm:flex-row"> <div className="flex flex-col sm:flex-row">
<Link href={`/berichte/${scan.id}`} className="flex-1 p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <Link href={`/berichte/${scan.id}`} className="flex-1 p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
@ -131,7 +251,9 @@ export default function ScanHistory() {
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
)}
</>
)} )}
</div> </div>
); );