feat(frontend): Koeniginnen-Views (Modul 4)

/queens: Liste; /queens/new: Anlegeformular; /queens/[id]: Bearbeiten
(Zucht/Herkunft/Status/Notizen), "Volk zuweisen"-Dialog (nur ACTIVE
Voelker zur Auswahl) und Volk-Historie. Bearbeitungsformular initialisiert
Zustand direkt aus der geladenen Queen (gleiches Key-Reset-Pattern wie
bei Apiaries/Colonies).

Nav-Leiste und proxy.ts-Matcher um /queens erweitert.

10 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow
(Anlegen, Volk zuweisen, Historie) live im Browser gegen den echten
Server verifiziert.
This commit is contained in:
Nick Szaunig committed 2026-08-21 18:35:33 +02:00
1 parent b18d61640d
commit 49ce23147c
12 files changed
+593 -2

No files matched your search

+7 -1
View File
@@ -22,5 +22,11 @@ export function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/login", "/settings/:path*", "/apiaries/:path*", "/colonies/:path*"],
matcher: [
"/login",
"/settings/:path*",
"/apiaries/:path*",
"/colonies/:path*",
"/queens/:path*",
],
};
@@ -31,6 +31,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/colonies">
Völker
</Link>
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/queens">
Königinnen
</Link>
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
@@ -0,0 +1,80 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { assignQueenColony } from "@/lib/queens/api";
import { fetchColonies } from "@/lib/colonies/api";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 mutation = useMutation({
mutationFn: () => assignQueenColony(queenId, { colonyId }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens", queenId] });
void queryClient.invalidateQueries({ queryKey: ["queens", queenId, "history"] });
setOpen(false);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline">Volk zuweisen</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Königin einem Volk zuweisen</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => {
event.preventDefault();
mutation.mutate();
}}
>
<div className="flex flex-col gap-2">
<Label htmlFor="assign-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="assign-colony">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{(colonies ?? [])
.filter((colony) => colony.status === "ACTIVE")
.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="submit" disabled={!colonyId || mutation.isPending}>
Zuweisen
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,35 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchQueen } from "@/lib/queens/api";
import { QueenEditCard } from "./queen-edit-card";
import { AssignColonyDialog } from "./assign-colony-dialog";
import { QueenHistoryCard } from "./queen-history";
export default function QueenDetailPage() {
const params = useParams<{ id: string }>();
const { data: queen, isLoading, isError } = useQuery({
queryKey: ["queens", params.id],
queryFn: () => fetchQueen(params.id),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !queen) {
return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
Königin konnte nicht geladen werden.
</p>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<QueenEditCard key={queen.id} queen={queen} />
<AssignColonyDialog queenId={queen.id} />
<QueenHistoryCard queenId={queen.id} />
</main>
);
}
@@ -0,0 +1,119 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateQueen } from "@/lib/queens/api";
import { Queen, QueenStatus } from "@/lib/queens/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const QUEEN_STATUSES: QueenStatus[] = [
"ACTIVE",
"RESERVE",
"UNMATED",
"MATED",
"LOST",
"REPLACED",
"SOLD",
"DECEASED",
];
export function QueenEditCard({ queen }: { queen: Queen }) {
const queryClient = useQueryClient();
const [breed, setBreed] = useState(queen.breed ?? "");
const [origin, setOrigin] = useState(queen.origin ?? "");
const [notes, setNotes] = useState(queen.notes ?? "");
const [status, setStatus] = useState<QueenStatus>(queen.status);
const mutation = useMutation({
mutationFn: () =>
updateQueen(queen.id, {
breed: breed || undefined,
origin: origin || undefined,
notes: notes || undefined,
status,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens"] });
void queryClient.invalidateQueries({ queryKey: ["queens", queen.id] });
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{queen.code}</CardTitle>
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{queen.status}
</Badge>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
<Input
id="queen-breed"
value={breed}
onChange={(event) => setBreed(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-origin">Herkunft</Label>
<Input
id="queen-origin"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
<SelectTrigger id="queen-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-notes">Notizen</Label>
<Textarea
id="queen-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchQueenHistory } from "@/lib/queens/api";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function QueenHistoryCard({ queenId }: { queenId: string }) {
const { data: history, isLoading } = useQuery({
queryKey: ["queens", queenId, "history"],
queryFn: () => fetchQueenHistory(queenId),
});
if (isLoading || !history) {
return <p className="text-sm text-muted-foreground">Historie lädt…</p>;
}
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Volk-Historie</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{history.length === 0 ? (
<p className="text-muted-foreground">Noch keine Historie vorhanden.</p>
) : null}
{history.map((entry) => (
<p key={entry.id}>
Seit {new Date(entry.startDate).toLocaleDateString("de-DE")}
{entry.endDate
? ` bis ${new Date(entry.endDate).toLocaleDateString("de-DE")}`
: " (aktuell)"}
</p>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,88 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createQueen } from "@/lib/queens/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function NewQueenPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [code, setCode] = useState("");
const [breed, setBreed] = useState("");
const [origin, setOrigin] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: createQueen,
onSuccess: (queen) => {
void queryClient.invalidateQueries({ queryKey: ["queens"] });
router.push(`/queens/${queen.id}`);
},
onError: () => {
setError("Königin konnte nicht angelegt werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({ code, breed: breed || undefined, origin: origin || undefined });
}
return (
<main className="p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Neue Königin</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-code">Code</Label>
<Input
id="queen-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
<Input
id="queen-breed"
value={breed}
onChange={(event) => setBreed(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-origin">Herkunft</Label>
<Input
id="queen-origin"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import QueensPage from "./page";
import { fetchQueens } from "@/lib/queens/api";
vi.mock("@/lib/queens/api", () => ({
fetchQueens: vi.fn(),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("QueensPage", () => {
beforeEach(() => {
vi.mocked(fetchQueens).mockReset();
});
it("renders the fetched queens", async () => {
vi.mocked(fetchQueens).mockResolvedValueOnce([
{
id: "1",
code: "Q-001",
hatchYear: null,
breed: "Carnica",
origin: null,
breeder: null,
markingColor: null,
markingNumber: null,
matingType: null,
matingStatus: null,
inseminationData: null,
motherLine: null,
fatherLine: null,
status: "ACTIVE",
notes: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
},
]);
renderWithClient(<QueensPage />);
expect(await screen.findByText("Q-001")).toBeInTheDocument();
expect(screen.getByText("Carnica")).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchQueens } from "@/lib/queens/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function QueensPage() {
const { data: queens, isLoading, isError } = useQuery({
queryKey: ["queens"],
queryFn: fetchQueens,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Königinnen</h1>
<Button nativeButton={false} render={<Link href="/queens/new" />}>
+ Neue Königin
</Button>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
Königinnen konnten nicht geladen werden.
</p>
) : null}
{queens ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Zucht</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queens.map((queen) => (
<TableRow key={queen.id}>
<TableCell>
<Link className="underline" href={`/queens/${queen.id}`}>
{queen.code}
</Link>
</TableCell>
<TableCell>{queen.breed ?? "—"}</TableCell>
<TableCell>
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{queen.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
AssignColonyInput,
CreateQueenInput,
Queen,
QueenColonyHistoryEntry,
UpdateQueenInput,
} from "./types";
export function fetchQueens(): Promise<Queen[]> {
return apiFetch<Queen[]>("/queens");
}
export function fetchQueen(id: string): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}`);
}
export function fetchQueenHistory(id: string): Promise<QueenColonyHistoryEntry[]> {
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${id}/history`);
}
export function createQueen(input: CreateQueenInput): Promise<Queen> {
return apiFetch<Queen>("/queens", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateQueen(id: string, input: UpdateQueenInput): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function assignQueenColony(id: string, input: AssignColonyInput): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}/assign-colony`, {
method: "POST",
body: JSON.stringify(input),
});
}
+57
View File
@@ -0,0 +1,57 @@
export type QueenStatus =
| "ACTIVE"
| "RESERVE"
| "UNMATED"
| "MATED"
| "LOST"
| "REPLACED"
| "SOLD"
| "DECEASED";
export interface Queen {
id: string;
code: string;
hatchYear: number | null;
breed: string | null;
origin: string | null;
breeder: string | null;
markingColor: string | null;
markingNumber: string | null;
matingType: string | null;
matingStatus: string | null;
inseminationData: string | null;
motherLine: string | null;
fatherLine: string | null;
status: QueenStatus;
notes: string | null;
createdAt: string;
updatedAt: string;
deactivatedAt: string | null;
}
export interface CreateQueenInput {
code: string;
breed?: string;
origin?: string;
notes?: string;
}
export interface UpdateQueenInput {
breed?: string;
origin?: string;
notes?: string;
status?: QueenStatus;
}
export interface AssignColonyInput {
colonyId: string;
notes?: string;
}
export interface QueenColonyHistoryEntry {
id: string;
colonyId: string;
startDate: string;
endDate: string | null;
notes: string | null;
}
+1 -1
View File
@@ -43,7 +43,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Endpunkte: `GET/POST /api/v1/queens`, `GET/PATCH /api/v1/queens/:id`, `POST /api/v1/queens/:id/assign-colony`, `GET /api/v1/queens/:id/history`
- Frontend: `/queens`, `/queens/[id]`, `/queens/new`
- [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet
- [x] Frontend-Views fertig, getestet, grün, committet
### 5. Durchsichten