feat(frontend): Suche und Filter (Modul 14)

/search-Seite über Bienenstände, Völker, Königinnen, Aufgaben und
Honigchargen. Filterleisten (Status/Standort) in den Listen-Views
für Bienenstände, Völker, Königinnen und Honigchargen.
This commit is contained in:
Nick Szaunig committed 2026-08-22 05:39:59 +02:00
1 parent 146b56d683
commit 166979ff3c
21 files changed
+314 -28

No files matched your search

@@ -1,10 +1,20 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { ApiaryStatus } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -15,9 +25,12 @@ import {
} from "@/components/ui/table";
export default function ApiariesPage() {
const [status, setStatus] = useState<ApiaryStatus | "">("");
const [location, setLocation] = useState("");
const { data: apiaries, isLoading, isError } = useQuery({
queryKey: ["apiaries"],
queryFn: fetchApiaries,
queryKey: ["apiaries", status, location],
queryFn: () => fetchApiaries({ status: status || undefined, location: location || undefined }),
});
return (
@@ -29,6 +42,28 @@ export default function ApiariesPage() {
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ApiaryStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
<SelectItem value="ACTIVE">Aktiv</SelectItem>
<SelectItem value="INACTIVE">Inaktiv</SelectItem>
</SelectContent>
</Select>
<Input
className="w-48"
placeholder="Standort"
value={location}
onChange={(event) => setLocation(event.target.value)}
/>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
@@ -47,7 +47,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState("");
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: fetchApiaries });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId }),
@@ -182,7 +182,7 @@ function MergeDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [mergedColonyId, setMergedColonyId] = useState("");
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: fetchColonies });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const mutation = useMutation({
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }),
@@ -35,7 +35,7 @@ const COLONY_TYPES: ColonyType[] = [
export default function NewColonyPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: fetchApiaries });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const [code, setCode] = useState("");
const [apiaryId, setApiaryId] = useState("");
@@ -1,10 +1,19 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchColonies } from "@/lib/colonies/api";
import { ColonyStatus } from "@/lib/colonies/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -14,10 +23,23 @@ import {
TableRow,
} from "@/components/ui/table";
const STATUS_OPTIONS: ColonyStatus[] = [
"ACTIVE",
"WEAK",
"CONSPICUOUS",
"QUEENLESS",
"DISSOLVED",
"SOLD",
"DECEASED",
"MERGED",
];
export default function ColoniesPage() {
const [status, setStatus] = useState<ColonyStatus | "">("");
const { data: colonies, isLoading, isError } = useQuery({
queryKey: ["colonies"],
queryFn: fetchColonies,
queryKey: ["colonies", status],
queryFn: () => fetchColonies({ status: status || undefined }),
});
return (
@@ -29,6 +51,23 @@ export default function ColoniesPage() {
</Button>
</div>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
@@ -38,7 +38,7 @@ export default function NewFeedingPage() {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: fetchColonies,
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
@@ -28,8 +28,8 @@ export default function NewHarvestPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: fetchApiaries });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: fetchColonies });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const [apiaryId, setApiaryId] = useState(searchParams.get("apiaryId") ?? "");
const [date, setDate] = useState(todayIsoDate());
@@ -1,9 +1,18 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { HoneyBatchStatus } from "@/lib/honey-batches/types";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -14,10 +23,23 @@ import {
} from "@/components/ui/table";
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
const STATUS_OPTIONS: HoneyBatchStatus[] = [
"RAW",
"EXTRACTED",
"CLARIFIED",
"CREAMED",
"STORED",
"BOTTLED",
"SOLD",
"CONSUMED",
];
export default function HoneyBatchesPage() {
const [status, setStatus] = useState<HoneyBatchStatus | "">("");
const { data: honeyBatches, isLoading, isError } = useQuery({
queryKey: ["honey-batches"],
queryFn: fetchHoneyBatches,
queryKey: ["honey-batches", status],
queryFn: () => fetchHoneyBatches({ status: status || undefined }),
});
return (
@@ -27,6 +49,25 @@ export default function HoneyBatchesPage() {
<CreateHoneyBatchDialog />
</div>
<Select
value={status || "ALL"}
onValueChange={(value) =>
setStatus(value === "ALL" ? "" : ((value ?? "") as HoneyBatchStatus))
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
@@ -29,7 +29,7 @@ export default function NewInspectionPage() {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: fetchColonies,
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
@@ -49,6 +49,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/storage">
Lager
</Link>
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/search">
Suche
</Link>
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
@@ -26,7 +26,7 @@ export function AssignColonyDialog({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState("");
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: fetchColonies });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const mutation = useMutation({
mutationFn: () => assignQueenColony(queenId, { colonyId }),
+41 -2
View File
@@ -1,10 +1,19 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchQueens } from "@/lib/queens/api";
import { QueenStatus } from "@/lib/queens/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -14,10 +23,23 @@ import {
TableRow,
} from "@/components/ui/table";
const STATUS_OPTIONS: QueenStatus[] = [
"ACTIVE",
"RESERVE",
"UNMATED",
"MATED",
"LOST",
"REPLACED",
"SOLD",
"DECEASED",
];
export default function QueensPage() {
const [status, setStatus] = useState<QueenStatus | "">("");
const { data: queens, isLoading, isError } = useQuery({
queryKey: ["queens"],
queryFn: fetchQueens,
queryKey: ["queens", status],
queryFn: () => fetchQueens({ status: status || undefined }),
});
return (
@@ -29,6 +51,23 @@ export default function QueensPage() {
</Button>
</div>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { search } from "@/lib/search/api";
import { SearchResult, SearchResultType } from "@/lib/search/types";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TYPE_LABELS: Record<SearchResultType, string> = {
Apiary: "Bienenstand",
Colony: "Volk",
Queen: "Königin",
Task: "Aufgabe",
HoneyBatch: "Honigcharge",
};
function resultHref(result: SearchResult): string {
switch (result.type) {
case "Apiary":
return `/apiaries/${result.id}`;
case "Colony":
return `/colonies/${result.id}`;
case "Queen":
return `/queens/${result.id}`;
case "HoneyBatch":
return `/honey-batches/${result.id}`;
case "Task":
return "/tasks";
}
}
export default function SearchPage() {
const [query, setQuery] = useState("");
const { data: results, isLoading } = useQuery({
queryKey: ["search", query],
queryFn: () => search(query),
enabled: query.trim().length > 0,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<h1 className="text-xl font-semibold">Suche</h1>
<Input
className="h-11 max-w-lg"
placeholder="Bienenstand, Volk, Königin, Aufgabe, Charge…"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{query.trim().length > 0 && !isLoading && (!results || results.length === 0) ? (
<p className="text-sm text-muted-foreground">Keine Treffer.</p>
) : null}
{results && results.length > 0 ? (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Ergebnisse</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{results.map((result) => (
<Link
key={`${result.type}-${result.id}`}
href={resultHref(result)}
className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-muted"
>
<span>{result.label}</span>
<Badge variant="secondary">{TYPE_LABELS[result.type]}</Badge>
</Link>
))}
</CardContent>
</Card>
) : null}
</main>
);
}
@@ -29,7 +29,7 @@ export default function NewTreatmentPage() {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: fetchColonies,
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
@@ -37,7 +37,7 @@ export default function NewVarroaMeasurementPage() {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: fetchColonies,
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
+12 -3
View File
@@ -1,8 +1,17 @@
import { apiFetch } from "@/lib/auth/api-client";
import { Apiary, ApiaryInput } from "./types";
import { Apiary, ApiaryInput, ApiaryStatus } from "./types";
export function fetchApiaries(): Promise<Apiary[]> {
return apiFetch<Apiary[]>("/apiaries");
export interface ApiaryFilter {
status?: ApiaryStatus;
location?: string;
}
export function fetchApiaries(filter: ApiaryFilter = {}): Promise<Apiary[]> {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
if (filter.location) params.set("location", filter.location);
const query = params.toString();
return apiFetch<Apiary[]>(`/apiaries${query ? `?${query}` : ""}`);
}
export function fetchApiary(id: string): Promise<Apiary> {
+12 -2
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import {
Colony,
ColonyHistory,
ColonyStatus,
CreateColonyInput,
MergeColonyInput,
MoveColonyInput,
@@ -9,8 +10,17 @@ import {
UpdateColonyInput,
} from "./types";
export function fetchColonies(): Promise<Colony[]> {
return apiFetch<Colony[]>("/colonies");
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
}
export function fetchColonies(filter: ColonyFilter = {}): Promise<Colony[]> {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
if (filter.apiaryId) params.set("apiaryId", filter.apiaryId);
const query = params.toString();
return apiFetch<Colony[]>(`/colonies${query ? `?${query}` : ""}`);
}
export function fetchColony(id: string): Promise<Colony> {
+10 -2
View File
@@ -3,11 +3,19 @@ import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
HoneyBatch,
HoneyBatchStatus,
Traceability,
} from "./types";
export function fetchHoneyBatches(): Promise<HoneyBatch[]> {
return apiFetch<HoneyBatch[]>("/honey-batches");
export interface HoneyBatchFilter {
status?: HoneyBatchStatus;
}
export function fetchHoneyBatches(filter: HoneyBatchFilter = {}): Promise<HoneyBatch[]> {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
const query = params.toString();
return apiFetch<HoneyBatch[]>(`/honey-batches${query ? `?${query}` : ""}`);
}
export function fetchHoneyBatch(id: string): Promise<HoneyBatch> {
+10 -2
View File
@@ -4,11 +4,19 @@ import {
CreateQueenInput,
Queen,
QueenColonyHistoryEntry,
QueenStatus,
UpdateQueenInput,
} from "./types";
export function fetchQueens(): Promise<Queen[]> {
return apiFetch<Queen[]>("/queens");
export interface QueenFilter {
status?: QueenStatus;
}
export function fetchQueens(filter: QueenFilter = {}): Promise<Queen[]> {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
const query = params.toString();
return apiFetch<Queen[]>(`/queens${query ? `?${query}` : ""}`);
}
export function fetchQueen(id: string): Promise<Queen> {
+6
View File
@@ -0,0 +1,6 @@
import { apiFetch } from "@/lib/auth/api-client";
import { SearchResult } from "./types";
export function search(q: string): Promise<SearchResult[]> {
return apiFetch<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`);
}
+7
View File
@@ -0,0 +1,7 @@
export type SearchResultType = "Apiary" | "Colony" | "Queen" | "Task" | "HoneyBatch";
export interface SearchResult {
type: SearchResultType;
id: string;
label: string;
}
+1 -1
View File
@@ -122,7 +122,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Backend: `SearchModule` (`GET /api/v1/search?q=`), Filter-Query-Parameter in bestehenden List-Endpunkten (Standort, Status, Zeitraum etc., kombinierbar)
- Frontend: `/search`, Filterleisten in allen Listen-Views
- [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet
- [x] Frontend-Views fertig, getestet, grün, committet
### 15. Responsive Smartphone-Oberfläche