feat: Schwarmkontrolle-Protokoll

Neues SwarmControlCheck-Modul: strukturierte Weiselzellenkontrollen pro
Volk mit Zahl/Verdeckelungsstatus der Zellen, Schwarmstimmung, ergriffener
Maßnahme und optionalem naechsten Kontrolltermin.

Frontend: neue Karte auf der Volk-Detailseite, Erfassungsformular unter
/swarm-control/new sowie Quick-Add-Eintrag.
This commit is contained in:
Nick Szaunig committed 2026-08-25 09:12:50 +02:00
1 parent 4609a0f9ec
commit 1a7331b6b2
19 files changed
+1076

No files matched your search

@@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { Sprout } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonySwarmControlChecks } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SwarmControlEditDialog } from "./swarm-control-edit-dialog";
export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
const { data: checks, isLoading } = useQuery({
queryKey: ["colonies", colonyId, "swarm-control-checks"],
queryFn: () => fetchColonySwarmControlChecks(colonyId),
});
return (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schwarmkontrolle</CardTitle>
<Button
size="sm"
nativeButton={false}
render={<Link href={`/swarm-control/new?colonyId=${colonyId}`} />}
>
Neue Kontrolle
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!checks || checks.length === 0) ? (
<EmptyState
icon={Sprout}
title="Noch keine Schwarmkontrolle"
description="Erfasse die erste Weiselzellenkontrolle für dieses Volk."
/>
) : null}
{checks?.map((check) => (
<div key={check.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
<SwarmControlEditDialog check={check} />
</div>
<p className="text-muted-foreground">
{check.queenCellsFound !== null
? `${check.queenCellsFound} Weiselzelle(n) gefunden`
: "Keine Weiselzellen erfasst"}
{check.queenCellsCapped ? (
<Badge variant="destructive" className="ml-2">
Verdeckelt
</Badge>
) : null}
</p>
<p>Schwarmstimmung: {SWARM_MOOD_LABELS[check.swarmMood]}</p>
{check.actionTaken !== "NONE" ? (
<p>Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}</p>
) : null}
{check.nextCheckDate ? (
<p>Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}</p>
) : null}
{check.notes ? <p>{check.notes}</p> : null}
</div>
))}
</CardContent>
</Card>
);
}
@@ -10,6 +10,7 @@ import { ColonyActions } from "./colony-actions";
import { ColonyHistoryCard } from "./colony-history";
import { ColonyInspectionsCard } from "./colony-inspections";
import { ColonyVarroaCard } from "./colony-varroa";
import { ColonySwarmControlCard } from "./colony-swarm-control";
import { ColonyTreatmentsCard } from "./colony-treatments";
import { ColonyFeedingsCard } from "./colony-feedings";
@@ -40,6 +41,7 @@ export default function ColonyDetailPage() {
<ColonyActions colony={colony} />
<ColonyInspectionsCard colonyId={colony.id} />
<ColonyVarroaCard colonyId={colony.id} />
<ColonySwarmControlCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} />
<ColonyFeedingsCard colonyId={colony.id} />
<ColonyHistoryCard colonyId={colony.id} />
@@ -0,0 +1,172 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types";
import { Button } from "@/components/ui/button";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(check.date.slice(0, 10));
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
const [swarmMood, setSwarmMood] = useState<SwarmMood>(check.swarmMood);
const [actionTaken, setActionTaken] = useState<SwarmControlAction>(check.actionTaken);
const [nextCheckDate, setNextCheckDate] = useState(check.nextCheckDate?.slice(0, 10) ?? "");
const [notes, setNotes] = useState(check.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateSwarmControlCheck(check.id, {
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
swarmMood,
actionTaken,
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-date">Datum</Label>
<Input
id="swarm-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-cells-found">Weiselzellen gefunden</Label>
<Input
id="swarm-edit-cells-found"
type="number"
min={0}
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
</div>
<label
className="flex h-9 items-center gap-3 text-sm"
htmlFor="swarm-edit-cells-capped"
>
<input
id="swarm-edit-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)}
/>
Weiselzellen bereits verdeckelt (akut)
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-mood">Schwarmstimmung</Label>
<Select
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id="swarm-edit-mood" className="w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-action">Maßnahme</Label>
<Select
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
}
>
<SelectTrigger id="swarm-edit-action" className="w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-next-check">Nächste Kontrolle</Label>
<Input
id="swarm-edit-next-check"
type="date"
value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-notes">Notizen</Label>
<Textarea
id="swarm-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -13,6 +13,7 @@ import {
const QUICK_ACTIONS = [
{ label: "Durchsicht", href: "/inspections/new" },
{ label: "Varroa-Messung", href: "/varroa/new" },
{ label: "Schwarmkontrolle", href: "/swarm-control/new" },
{ label: "Behandlung", href: "/treatments/new" },
{ label: "Fütterung", href: "/feedings/new" },
{ label: "Aufgabe", href: "/tasks/new" },
@@ -0,0 +1,212 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
import { Button } from "@/components/ui/button";
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 { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewSwarmControlCheckPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [queenCellsFound, setQueenCellsFound] = useState("");
const [queenCellsCapped, setQueenCellsCapped] = useState(false);
const [swarmMood, setSwarmMood] = useState<SwarmMood>("NONE");
const [actionTaken, setActionTaken] = useState<SwarmControlAction>("NONE");
const [nextCheckDate, setNextCheckDate] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: createSwarmControlCheck,
onSuccess: (check) => {
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
toast.success("Schwarmkontrolle gespeichert");
router.push(`/colonies/${check.colonyId}`);
},
onError: () => {
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!colonyId) {
setError("Bitte ein Volk auswählen.");
return;
}
mutation.mutate({
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
swarmMood,
actionTaken,
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
notes: notes || undefined,
});
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Sprout}
title="Neue Schwarmkontrolle"
description="Erfasse eine Weiselzellenkontrolle für ein Volk."
gradient="var(--gradient-sky)"
/>
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="swarm-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-date">Datum</Label>
<Input
id="swarm-date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-cells-found">Weiselzellen gefunden</Label>
<Input
id="swarm-cells-found"
type="number"
min={0}
inputMode="numeric"
className="h-11"
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-3 text-sm" htmlFor="swarm-cells-capped">
<input
id="swarm-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)}
/>
Weiselzellen bereits verdeckelt (akut)
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-mood">Schwarmstimmung</Label>
<Select
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id="swarm-mood" className="h-11 w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-action">Maßnahme</Label>
<Select
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
}
>
<SelectTrigger id="swarm-action" className="h-11 w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-next-check">Nächste Kontrolle</Label>
<Input
id="swarm-next-check"
type="date"
className="h-11"
value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-notes">Notizen</Label>
<Textarea
id="swarm-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Kontrolle speichern"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,29 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateSwarmControlCheckInput,
SwarmControlCheck,
UpdateSwarmControlCheckInput,
} from "./types";
export function fetchColonySwarmControlChecks(colonyId: string): Promise<SwarmControlCheck[]> {
return apiFetch<SwarmControlCheck[]>(`/colonies/${colonyId}/swarm-control-checks`);
}
export function createSwarmControlCheck(
input: CreateSwarmControlCheckInput,
): Promise<SwarmControlCheck> {
return apiFetch<SwarmControlCheck>("/swarm-control-checks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateSwarmControlCheck(
id: string,
input: UpdateSwarmControlCheckInput,
): Promise<SwarmControlCheck> {
return apiFetch<SwarmControlCheck>(`/swarm-control-checks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,15 @@
import { SwarmControlAction, SwarmMood } from "./types";
export const SWARM_MOOD_LABELS: Record<SwarmMood, string> = {
NONE: "Keine",
LOW: "Gering",
HIGH: "Hoch",
};
export const SWARM_ACTION_LABELS: Record<SwarmControlAction, string> = {
NONE: "Keine",
QUEEN_CELLS_REMOVED: "Weiselzellen gebrochen",
ARTIFICIAL_SWARM: "Kunstschwarm gebildet",
COLONY_DIVIDED: "Volk geteilt",
OTHER: "Sonstiges",
};
@@ -0,0 +1,42 @@
export type SwarmMood = "NONE" | "LOW" | "HIGH";
export type SwarmControlAction =
| "NONE"
| "QUEEN_CELLS_REMOVED"
| "ARTIFICIAL_SWARM"
| "COLONY_DIVIDED"
| "OTHER";
export interface SwarmControlCheck {
id: string;
colonyId: string;
userId: string;
date: string;
queenCellsFound: number | null;
queenCellsCapped: boolean;
swarmMood: SwarmMood;
actionTaken: SwarmControlAction;
notes: string | null;
nextCheckDate: string | null;
createdAt: string;
}
export interface CreateSwarmControlCheckInput {
colonyId: string;
date: string;
queenCellsFound?: number;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
}
export interface UpdateSwarmControlCheckInput {
date?: string;
queenCellsFound?: number;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
}