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>
);
}