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:
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
WinterCheck,
|
||||
WinterCheckInput,
|
||||
WinterCheckWithColony,
|
||||
WinterSummary,
|
||||
} from "./types";
|
||||
|
||||
export function fetchWinterChecks(winterYear: number): Promise<WinterCheckWithColony[]> {
|
||||
return apiFetch<WinterCheckWithColony[]>(`/winter-checks?winterYear=${winterYear}`);
|
||||
}
|
||||
|
||||
export function fetchWinterSummary(winterYear: number): Promise<WinterSummary> {
|
||||
return apiFetch<WinterSummary>(`/winter-checks/summary?winterYear=${winterYear}`);
|
||||
}
|
||||
|
||||
export function createWinterCheck(
|
||||
input: WinterCheckInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<WinterCheck> {
|
||||
return apiFetch<WinterCheck>("/winter-checks", {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWinterCheck(
|
||||
id: string,
|
||||
input: Partial<WinterCheckInput>,
|
||||
): Promise<WinterCheck> {
|
||||
return apiFetch<WinterCheck>(`/winter-checks/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ColonyLossCause, WinterOutcome } from "./types";
|
||||
|
||||
export const WINTER_OUTCOME_LABELS: Record<WinterOutcome, string> = {
|
||||
SURVIVED: "Überlebt",
|
||||
WEAK: "Schwach",
|
||||
LOST: "Verloren",
|
||||
};
|
||||
|
||||
/** Loss categories of the COLOSS monitoring - no diagnosis. */
|
||||
export const LOSS_CAUSE_LABELS: Record<ColonyLossCause, string> = {
|
||||
DEAD_OR_EMPTY: "Tot oder Beute leer",
|
||||
QUEEN_PROBLEM: "Unlösbares Königinnenproblem",
|
||||
NATURAL_DISASTER: "Naturereignis (Sturm, Hochwasser, Tiere …)",
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
export type WinterCheckSeason = "AUTUMN" | "SPRING";
|
||||
export type WinterOutcome = "SURVIVED" | "WEAK" | "LOST";
|
||||
export type ColonyLossCause = "DEAD_OR_EMPTY" | "QUEEN_PROBLEM" | "NATURAL_DISASTER";
|
||||
|
||||
export interface WinterCheck {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
userId: string;
|
||||
winterYear: number;
|
||||
season: WinterCheckSeason;
|
||||
date: string;
|
||||
occupiedSeams: number | null;
|
||||
feedStockKg: number | null;
|
||||
queenPresent: boolean | null;
|
||||
outcome: WinterOutcome | null;
|
||||
lossCause: ColonyLossCause | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WinterCheckWithColony extends WinterCheck {
|
||||
colony: {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
apiaryId: string;
|
||||
apiary: { id: string; name: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface WinterCheckInput {
|
||||
colonyId: string;
|
||||
season: WinterCheckSeason;
|
||||
date: string;
|
||||
winterYear?: number;
|
||||
occupiedSeams?: number;
|
||||
feedStockKg?: number;
|
||||
queenPresent?: boolean;
|
||||
outcome?: WinterOutcome;
|
||||
lossCause?: ColonyLossCause;
|
||||
notes?: string;
|
||||
/** With outcome LOST: also mark the colony as deceased. */
|
||||
markColonyDeceased?: boolean;
|
||||
}
|
||||
|
||||
export interface WinterCounts {
|
||||
wintered: number;
|
||||
survived: number;
|
||||
weak: number;
|
||||
lost: number;
|
||||
lostByCause: Record<ColonyLossCause, number>;
|
||||
pendingSpring: number;
|
||||
springWithoutAutumn: number;
|
||||
lossRatePercent: number | null;
|
||||
isProvisional: boolean;
|
||||
}
|
||||
|
||||
export interface WinterSummary {
|
||||
total: WinterCounts;
|
||||
apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import type { WinterCheckWithColony } from "./types";
|
||||
import { buildWinterRows, winterLabel, winterYearFor } from "./winter";
|
||||
|
||||
function colony(overrides: Partial<Colony>): Colony {
|
||||
return {
|
||||
id: "c1",
|
||||
code: "V-01",
|
||||
apiaryId: "a",
|
||||
hiveType: null,
|
||||
originText: null,
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
notes: null,
|
||||
currentQueenId: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deactivatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const deadColony = {
|
||||
id: "c1",
|
||||
code: "V-01",
|
||||
status: "DECEASED",
|
||||
apiaryId: "a",
|
||||
apiary: { id: "a", name: "Obstwiese" },
|
||||
};
|
||||
|
||||
function check(overrides: Partial<WinterCheckWithColony>): WinterCheckWithColony {
|
||||
return {
|
||||
id: "w1",
|
||||
colonyId: "c1",
|
||||
userId: "u",
|
||||
winterYear: 2026,
|
||||
season: "AUTUMN",
|
||||
date: "2026-10-01T00:00:00.000Z",
|
||||
occupiedSeams: null,
|
||||
feedStockKg: null,
|
||||
queenPresent: null,
|
||||
outcome: null,
|
||||
lossCause: null,
|
||||
notes: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
colony: { ...deadColony, status: "ACTIVE" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("winterYearFor / winterLabel", () => {
|
||||
it("maps autumn and spring to the same winter", () => {
|
||||
expect(winterYearFor(new Date(2026, 9, 1))).toBe(2026);
|
||||
expect(winterYearFor(new Date(2027, 2, 20))).toBe(2026);
|
||||
expect(winterLabel(2026)).toBe("Winter 2026/27");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWinterRows", () => {
|
||||
it("lists living colonies without records and hides old dead ones", () => {
|
||||
const groups = buildWinterRows(
|
||||
[colony({ id: "c1" }), colony({ id: "c2", code: "V-02", status: "DECEASED" })],
|
||||
[],
|
||||
new Map([["a", "Obstwiese"]]),
|
||||
);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].rows.map((row) => row.colonyId)).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("keeps a colony that died this winter, with its loss record", () => {
|
||||
const groups = buildWinterRows(
|
||||
[],
|
||||
[
|
||||
check({ colony: deadColony }),
|
||||
check({
|
||||
id: "w2",
|
||||
season: "SPRING",
|
||||
outcome: "LOST",
|
||||
lossCause: "DEAD_OR_EMPTY",
|
||||
colony: deadColony,
|
||||
}),
|
||||
],
|
||||
new Map(),
|
||||
);
|
||||
const [row] = groups[0].rows;
|
||||
expect(row).toMatchObject({ isLiving: false });
|
||||
expect(row.autumn?.id).toBe("w1");
|
||||
expect(row.spring?.outcome).toBe("LOST");
|
||||
expect(groups[0].apiaryName).toBe("Obstwiese");
|
||||
});
|
||||
|
||||
it("sorts colony codes naturally", () => {
|
||||
const groups = buildWinterRows(
|
||||
[colony({ id: "c10", code: "V-10" }), colony({ id: "c2", code: "V-2" })],
|
||||
[],
|
||||
new Map([["a", "Obstwiese"]]),
|
||||
);
|
||||
expect(groups[0].rows.map((row) => row.code)).toEqual(["V-2", "V-10"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import type { WinterCheckWithColony } from "./types";
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["DISSOLVED", "SOLD", "DECEASED", "MERGED"]);
|
||||
|
||||
/** From July on a date belongs to the winter starting that year. */
|
||||
export function winterYearFor(date: Date): number {
|
||||
return date.getMonth() >= 6 ? date.getFullYear() : date.getFullYear() - 1;
|
||||
}
|
||||
|
||||
export function winterLabel(winterYear: number): string {
|
||||
return `Winter ${winterYear}/${String(winterYear + 1).slice(-2)}`;
|
||||
}
|
||||
|
||||
export interface WinterRow {
|
||||
colonyId: string;
|
||||
code: string;
|
||||
isLiving: boolean;
|
||||
autumn: WinterCheckWithColony | null;
|
||||
spring: WinterCheckWithColony | null;
|
||||
}
|
||||
|
||||
export interface WinterApiaryGroup {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
rows: WinterRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per colony that is alive now or has a record in this winter, so a
|
||||
* colony that died still shows up with its loss, grouped by apiary.
|
||||
*/
|
||||
export function buildWinterRows(
|
||||
colonies: Colony[],
|
||||
checks: WinterCheckWithColony[],
|
||||
apiaryNames: ReadonlyMap<string, string>,
|
||||
): WinterApiaryGroup[] {
|
||||
const names = new Map(apiaryNames);
|
||||
const rows = new Map<string, WinterRow & { apiaryId: string }>();
|
||||
for (const colony of colonies) {
|
||||
if (TERMINAL_STATUSES.has(colony.status)) continue;
|
||||
rows.set(colony.id, {
|
||||
colonyId: colony.id,
|
||||
code: colony.code,
|
||||
apiaryId: colony.apiaryId,
|
||||
isLiving: true,
|
||||
autumn: null,
|
||||
spring: null,
|
||||
});
|
||||
}
|
||||
for (const check of checks) {
|
||||
const existing = rows.get(check.colonyId) ?? {
|
||||
colonyId: check.colonyId,
|
||||
code: check.colony.code,
|
||||
apiaryId: check.colony.apiaryId,
|
||||
isLiving: !TERMINAL_STATUSES.has(check.colony.status),
|
||||
autumn: null,
|
||||
spring: null,
|
||||
};
|
||||
rows.set(check.colonyId, {
|
||||
...existing,
|
||||
...(check.season === "AUTUMN" ? { autumn: check } : { spring: check }),
|
||||
});
|
||||
if (!names.has(check.colony.apiaryId)) {
|
||||
names.set(check.colony.apiaryId, check.colony.apiary.name);
|
||||
}
|
||||
}
|
||||
const groups = new Map<string, WinterApiaryGroup>();
|
||||
for (const { apiaryId, ...row } of rows.values()) {
|
||||
const group = groups.get(apiaryId) ?? {
|
||||
apiaryId,
|
||||
apiaryName: names.get(apiaryId) ?? "Unbekannter Stand",
|
||||
rows: [],
|
||||
};
|
||||
groups.set(apiaryId, { ...group, rows: [...group.rows, row] });
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
rows: [...group.rows].sort((a, b) => a.code.localeCompare(b.code, "de", { numeric: true })),
|
||||
}))
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, "de"));
|
||||
}
|
||||
Reference in new issue
Block a user