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:
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 |
|
|
@ -1,22 +1,63 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
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 { VerdictBadge, RelationBadge } from "@/components/ui-helpers";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const { data: scans, isLoading } = useListScans();
|
||||
const queryClient = useQueryClient();
|
||||
const deleteScan = useDeleteScan();
|
||||
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) => {
|
||||
deleteScan.mutate({ id }, {
|
||||
onSuccess: () => {
|
||||
|
|
@ -58,9 +99,88 @@ export default function ScanHistory() {
|
|||
<Link href="/pruefen">Jetzt einen Skill prüfen</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
{scans.map((scan) => (
|
||||
{filteredScans.map((scan) => (
|
||||
<Card key={scan.id} className="overflow-hidden hover:border-primary/50 transition-colors">
|
||||
<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">
|
||||
|
|
@ -133,6 +253,8 @@ export default function ScanHistory() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue