feat: Ein-/Auswinterung und Winterverluste je Volk

- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:32:03 +02:00
1 parent 0d23963f07
commit fac66882fa
26 files changed
+2020 -2

No files matched your search

@@ -20,6 +20,7 @@ import {
Package,
Scissors,
Search,
Snowflake,
ShoppingBag,
Users,
Warehouse,
@@ -58,6 +59,12 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
@@ -0,0 +1,261 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Snowflake } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
import { ColonyLossCause, WinterCheckWithColony, WinterCounts } from "@/lib/winter-checks/types";
import { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { WinterCheckDialog, WinterCheckTarget } from "./winter-check-dialog";
const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit" });
function autumnText(check: WinterCheckWithColony): string {
const parts = [DATE.format(new Date(check.date))];
if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen`);
if (check.feedStockKg !== null) {
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
}
return parts.join(" · ");
}
function SummaryCards({ counts }: { counts: WinterCounts }) {
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
const causes = (Object.keys(counts.lostByCause) as ColonyLossCause[]).filter(
(cause) => counts.lostByCause[cause] > 0,
);
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="Eingewintert" value={counts.wintered} />
<Stat label="Überlebt" value={counts.survived} />
<Stat label="Schwach" value={counts.weak} />
<Stat
label="Verloren"
value={counts.lost}
hint={causes.map((cause) => `${counts.lostByCause[cause]}× ${LOSS_CAUSE_LABELS[cause]}`).join(", ")}
/>
<Stat
label={counts.isProvisional ? "Verlustrate (vorläufig)" : "Verlustrate"}
value={
counts.lossRatePercent === null || !hasSpringResults
? "–"
: `${counts.lossRatePercent.toLocaleString("de-DE")} %`
}
hint={
counts.pendingSpring > 0
? `${counts.pendingSpring} Volk/Völker noch nicht ausgewintert`
: undefined
}
/>
</div>
);
}
function Stat({ label, value, hint }: { label: string; value: number | string; hint?: string }) {
return (
<div className="rounded-xl border border-border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-heading text-2xl font-semibold tabular-nums">{value}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
function RowActions({
row,
canCreate,
canUpdate,
isSpringDue,
onOpen,
}: {
row: WinterRow;
canCreate: boolean;
canUpdate: boolean;
/** The winter is over (January on) - Auswinterung also without Einwinterung record. */
isSpringDue: boolean;
onOpen: (season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
{row.autumn ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("AUTUMN", row.autumn)}>
Eingewintert {autumnText(row.autumn)}
</Button>
) : row.isLiving && canCreate ? (
<Button variant="outline" size="touch" onClick={() => onOpen("AUTUMN", null)}>
Einwintern
</Button>
) : (
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
)}
{row.spring?.outcome ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
<Badge
variant={
row.spring.outcome === "LOST"
? "destructive"
: row.spring.outcome === "WEAK"
? "warning"
: "default"
}
>
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
</Badge>
{row.spring.lossCause ? LOSS_CAUSE_LABELS[row.spring.lossCause] : null}
</Button>
) : canCreate && (row.autumn || isSpringDue) ? (
<Button variant="outline" size="touch" onClick={() => onOpen("SPRING", null)}>
Auswintern
</Button>
) : null}
</div>
);
}
export default function WinterPage() {
const { hasPermission } = useAuth();
const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date()));
const [target, setTarget] = useState<WinterCheckTarget | null>(null);
const canCreate = hasPermission("winter-checks:create");
const canUpdate = hasPermission("winter-checks:update");
const checksQuery = useQuery({
queryKey: ["winter-checks", winterYear],
queryFn: () => fetchWinterChecks(winterYear),
});
const summaryQuery = useQuery({
queryKey: ["winter-checks", "summary", winterYear],
queryFn: () => fetchWinterSummary(winterYear),
});
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const groups = useMemo(
() =>
buildWinterRows(
coloniesQuery.data ?? [],
checksQuery.data ?? [],
new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])),
),
[coloniesQuery.data, checksQuery.data, apiariesQuery.data],
);
// One action per row until the colony is wintered: from January of the
// following year on, Auswinterung is offered for every colony.
const isSpringDue = new Date() >= new Date(winterYear + 1, 0, 1);
const openAutumn = groups.reduce(
(sum, group) => sum + group.rows.filter((row) => row.isLiving && !row.autumn).length,
0,
);
function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) {
setTarget({
colonyId: row.colonyId,
code: row.code,
isLiving: row.isLiving,
season,
winterYear,
existing,
});
}
const isLoading = checksQuery.isLoading || coloniesQuery.isLoading;
const isError = checksQuery.isError || coloniesQuery.isError;
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Snowflake}
title="Überwinterung"
description="Ein- und Auswinterung je Volk, Verluste mit Ursache."
actions={
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
aria-label="Vorheriger Winter"
onClick={() => setWinterYear((year) => year - 1)}
>
<ChevronLeft />
</Button>
<span className="min-w-36 text-center font-medium tabular-nums">{winterLabel(winterYear)}</span>
<Button
variant="outline"
size="icon"
aria-label="Nächster Winter"
onClick={() => setWinterYear((year) => year + 1)}
>
<ChevronRight />
</Button>
</div>
}
/>
{openAutumn > 0 && canCreate && !isSpringDue ? (
<p className="rounded-xl bg-muted px-4 py-3 text-sm">
<span className="font-medium">Als Nächstes:</span> {openAutumn} Volk/Völker noch nicht
eingewintert.
</p>
) : null}
{summaryQuery.data ? <SummaryCards counts={summaryQuery.data.total} /> : null}
{isError ? (
<p className="text-sm text-destructive">
Daten konnten nicht geladen werden. Bitte die Seite neu laden.
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">Wird geladen…</p>
) : groups.length === 0 ? (
<EmptyState
icon={Snowflake}
title="Keine Völker"
description="Sobald Völker angelegt sind, lassen sie sich hier ein- und auswintern."
/>
) : (
groups.map((group) => (
<Card key={group.apiaryId}>
<CardHeader>
<CardTitle>{group.apiaryName}</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col divide-y divide-border">
{group.rows.map((row) => (
<li
key={row.colonyId}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<span className="font-medium">
{row.code}
{!row.isLiving ? (
<span className="ml-2 text-xs text-muted-foreground">(besteht nicht mehr)</span>
) : null}
</span>
<RowActions
row={row}
canCreate={canCreate}
canUpdate={canUpdate}
isSpringDue={isSpringDue}
onOpen={(season, existing) => open(row, season, existing)}
/>
</li>
))}
</ul>
</CardContent>
</Card>
))
)}
{target ? <WinterCheckDialog target={target} onClose={() => setTarget(null)} /> : null}
</div>
);
}
@@ -0,0 +1,269 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { createWinterCheck, updateWinterCheck } from "@/lib/winter-checks/api";
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
import {
ColonyLossCause,
WinterCheck,
WinterCheckInput,
WinterCheckSeason,
WinterOutcome,
} from "@/lib/winter-checks/types";
import { winterLabel } from "@/lib/winter-checks/winter";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface WinterCheckTarget {
colonyId: string;
code: string;
isLiving: boolean;
season: WinterCheckSeason;
winterYear: number;
existing: WinterCheck | null;
}
const QUEEN_OPTIONS = { yes: "Ja", no: "Nein", unknown: "Nicht geprüft" } as const;
type QueenOption = keyof typeof QUEEN_OPTIONS;
function queenOption(value: boolean | null | undefined): QueenOption {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function toNumber(value: string): number | undefined {
const trimmed = value.trim().replace(",", ".");
return trimmed === "" ? undefined : Number(trimmed);
}
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"];
export function WinterCheckDialog({
target,
onClose,
}: {
target: WinterCheckTarget;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const existing = target.existing;
const isSpring = target.season === "SPRING";
const [date, setDate] = useState(
existing?.date.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
);
const [occupiedSeams, setOccupiedSeams] = useState(existing?.occupiedSeams?.toString() ?? "");
const [feedStockKg, setFeedStockKg] = useState(existing?.feedStockKg?.toString() ?? "");
const [queen, setQueen] = useState<QueenOption>(queenOption(existing?.queenPresent));
const [outcome, setOutcome] = useState<WinterOutcome | null>(existing?.outcome ?? null);
const [lossCause, setLossCause] = useState<ColonyLossCause | "">(existing?.lossCause ?? "");
const [markDeceased, setMarkDeceased] = useState(target.isLiving);
const [notes, setNotes] = useState(existing?.notes ?? "");
const [idempotencyKey] = useState(newIdempotencyKey);
const isLost = outcome === "LOST";
const missing = isSpring && (!outcome || (isLost && !lossCause));
const mutation = useMutation({
mutationFn: () => {
const input: WinterCheckInput = {
colonyId: target.colonyId,
season: target.season,
winterYear: target.winterYear,
date: new Date(date).toISOString(),
occupiedSeams: toNumber(occupiedSeams),
feedStockKg: isSpring ? undefined : toNumber(feedStockKg),
queenPresent: queen === "unknown" ? undefined : queen === "yes",
outcome: outcome ?? undefined,
lossCause: isLost && lossCause ? lossCause : undefined,
notes: notes.trim() || undefined,
markColonyDeceased: isLost && target.isLiving ? markDeceased : undefined,
};
return existing
? updateWinterCheck(existing.id, input)
: createWinterCheck(input, { idempotencyKey });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["winter-checks"] });
if (isLost && markDeceased) {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
}
toast.success(`${target.code}: gespeichert`);
onClose();
},
onError: (error) =>
toast.error(
error instanceof ApiError ? error.message : "Speichern fehlgeschlagen. Bitte noch einmal versuchen.",
),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (missing) return;
mutation.mutate();
}
const title = `${target.code} ${isSpring ? "auswintern" : "einwintern"}`;
return (
<Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<p className="text-sm text-muted-foreground">{winterLabel(target.winterYear)}</p>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{isSpring ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
<div className="grid grid-cols-3 gap-2">
{OUTCOMES.map((value) => (
<Button
key={value}
type="button"
size="touch"
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
aria-pressed={outcome === value}
onClick={() => setOutcome(value)}
>
{WINTER_OUTCOME_LABELS[value]}
</Button>
))}
</div>
</fieldset>
) : null}
{isLost ? (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 p-3">
<div className="flex flex-col gap-2">
<Label htmlFor="winter-loss-cause">Verlustursache</Label>
<Select
value={lossCause}
onValueChange={(value) => setLossCause((value ?? "") as ColonyLossCause | "")}
>
<SelectTrigger id="winter-loss-cause" className="h-11 w-full">
<SelectValue placeholder="Ursache wählen">
{lossCause ? LOSS_CAUSE_LABELS[lossCause] : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(LOSS_CAUSE_LABELS) as ColonyLossCause[]).map((cause) => (
<SelectItem key={cause} value={cause}>
{LOSS_CAUSE_LABELS[cause]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Kategorien wie im COLOSS-Monitoring. Vermutete Gründe (z. B. Varroa) gern in die Notiz.
</p>
</div>
{target.isLiving ? (
<div className="flex items-center gap-2">
<Checkbox
id="winter-mark-deceased"
checked={markDeceased}
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
/>
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
</div>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="winter-date">Datum</Label>
<Input
id="winter-date"
type="date"
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
required
/>
</div>
{!isLost ? (
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}>
<div className="flex flex-col gap-2">
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
<Input
id="winter-seams"
inputMode="numeric"
className="h-11"
value={occupiedSeams}
onChange={(event) => setOccupiedSeams(event.target.value)}
/>
</div>
{!isSpring ? (
<div className="flex flex-col gap-2">
<Label htmlFor="winter-feed">Futtervorrat (kg, geschätzt)</Label>
<Input
id="winter-feed"
inputMode="decimal"
className="h-11"
value={feedStockKg}
onChange={(event) => setFeedStockKg(event.target.value)}
/>
</div>
) : null}
</div>
) : null}
{!isLost ? (
<div className="flex flex-col gap-2">
<Label htmlFor="winter-queen">Königin/Stifte gesehen</Label>
<Select value={queen} onValueChange={(value) => setQueen((value ?? "unknown") as QueenOption)}>
<SelectTrigger id="winter-queen" className="h-11 w-full">
<SelectValue>{QUEEN_OPTIONS[queen]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(QUEEN_OPTIONS) as QueenOption[]).map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_OPTIONS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="winter-notes">Notiz</Label>
<Textarea id="winter-notes" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="touch" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" size="touch" disabled={missing || mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</div>
{missing ? (
<p className="text-right text-xs text-muted-foreground">
{outcome ? "Bitte die Verlustursache wählen." : "Bitte zuerst das Ergebnis wählen."}
</p>
) : null}
</form>
</DialogContent>
</Dialog>
);
}