feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonyActions } from "./colony-actions";
|
||||
import { fetchColonies, mergeColony } from "@/lib/colonies/api";
|
||||
import { fetchColonies, mergeColony, splitColony } from "@/lib/colonies/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import type { Colony, ColonyStatus } from "@/lib/colonies/types";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({
|
||||
@@ -31,6 +33,7 @@ function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colo
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status,
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
@@ -140,3 +143,106 @@ describe("ColonyActions – colonies that need attention", () => {
|
||||
expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColonyActions – split", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue([target]);
|
||||
vi.mocked(splitColony).mockReset();
|
||||
vi.mocked(splitColony).mockResolvedValue(other);
|
||||
});
|
||||
|
||||
async function openSplit() {
|
||||
renderActions();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ableger bilden" }));
|
||||
return screen.findByRole("dialog");
|
||||
}
|
||||
|
||||
const originGroup = (dialog: HTMLElement) =>
|
||||
within(dialog).getByRole("radiogroup", { name: /Woher kommt die Königin\?/ });
|
||||
|
||||
it("the split dialog sends date and queenSource with nothing preselected", async () => {
|
||||
const dialog = await openSplit();
|
||||
|
||||
const group = originGroup(dialog);
|
||||
const radios = within(group).getAllByRole("radio");
|
||||
expect(radios.map((radio) => radio.textContent)).toEqual([
|
||||
"Eigene Zellen ziehen",
|
||||
"Weiselzelle",
|
||||
"Königin",
|
||||
"Weiß ich noch nicht",
|
||||
]);
|
||||
expect(radios.every((radio) => radio.getAttribute("aria-checked") === "false")).toBe(true);
|
||||
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
|
||||
expect(date).toHaveValue(todayLocalIsoDate());
|
||||
expect(date).toHaveAttribute("max", todayLocalIsoDate());
|
||||
|
||||
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-01");
|
||||
fireEvent.change(date, { target: { value: "2026-10-10" } });
|
||||
await userEvent.click(within(group).getByRole("radio", { name: "Eigene Zellen ziehen" }));
|
||||
expect(
|
||||
within(dialog).getByText("Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
|
||||
|
||||
expect(splitColony).toHaveBeenCalledWith("colony-1", {
|
||||
code: "N-01",
|
||||
colonyType: "NUCLEUS_COLONY",
|
||||
date: "2026-10-10",
|
||||
queenSource: "RAISE_OWN",
|
||||
});
|
||||
});
|
||||
|
||||
it("states the consequence of each origin", async () => {
|
||||
const dialog = await openSplit();
|
||||
const group = originGroup(dialog);
|
||||
|
||||
await userEvent.click(within(group).getByRole("radio", { name: "Weiselzelle" }));
|
||||
expect(within(dialog).getByText("Es entsteht ein Plan: Stifte prüfen.")).toBeInTheDocument();
|
||||
await userEvent.click(within(group).getByRole("radio", { name: "Königin" }));
|
||||
expect(
|
||||
within(dialog).getByText("Es entsteht ein Plan: Annahme der Königin prüfen."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(within(group).getByRole("radio", { name: "Weiß ich noch nicht" }));
|
||||
expect(within(dialog).getByText("Ohne Angabe entsteht kein Plan.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("„Weiß ich noch nicht“ sends no queenSource", async () => {
|
||||
const dialog = await openSplit();
|
||||
|
||||
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-02");
|
||||
await userEvent.click(
|
||||
within(originGroup(dialog)).getByRole("radio", { name: "Weiß ich noch nicht" }),
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
|
||||
|
||||
const [, input] = vi.mocked(splitColony).mock.calls[0];
|
||||
expect(input).toMatchObject({ code: "N-02", date: todayLocalIsoDate() });
|
||||
expect(input).not.toHaveProperty("queenSource");
|
||||
});
|
||||
|
||||
it("a future date shows the server message at the date field", async () => {
|
||||
vi.mocked(splitColony).mockRejectedValue(
|
||||
new ApiError(400, "Das Datum liegt in der Zukunft."),
|
||||
);
|
||||
const dialog = await openSplit();
|
||||
|
||||
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-03");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
|
||||
|
||||
const message = await within(dialog).findByText("Das Datum liegt in der Zukunft.");
|
||||
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
|
||||
expect(date).toHaveAttribute("aria-invalid", "true");
|
||||
expect(date.getAttribute("aria-describedby")).toContain(message.id);
|
||||
});
|
||||
|
||||
it("asks for the date when it was cleared", async () => {
|
||||
const dialog = await openSplit();
|
||||
|
||||
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-04");
|
||||
fireEvent.change(within(dialog).getByLabelText(/Datum des Ablegers/), { target: { value: "" } });
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
|
||||
|
||||
expect(within(dialog).getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
|
||||
expect(splitColony).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
reactivateColony,
|
||||
splitColony,
|
||||
} from "@/lib/colonies/api";
|
||||
import { Colony, ColonyType } from "@/lib/colonies/types";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import { Colony, ColonyType, SplitQueenSource } from "@/lib/colonies/types";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, SPLIT_QUEEN_SOURCE_LABELS } from "@/lib/colonies/labels";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
@@ -21,6 +23,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -136,24 +139,63 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** "" = not answered yet; "UNKNOWN" = the beekeeper said they do not know. */
|
||||
type QueenOriginChoice = SplitQueenSource | "UNKNOWN" | "";
|
||||
|
||||
const QUEEN_ORIGIN_OPTIONS: readonly ChoiceOption<QueenOriginChoice>[] = [
|
||||
...choiceOptions<SplitQueenSource>(SPLIT_QUEEN_SOURCE_LABELS),
|
||||
{ value: "UNKNOWN", label: "Weiß ich noch nicht" },
|
||||
];
|
||||
|
||||
/** What follows from the choice: the chain of checks the server plans. */
|
||||
const QUEEN_ORIGIN_CONSEQUENCE: Record<QueenOriginChoice, string> = {
|
||||
RAISE_OWN: "Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.",
|
||||
QUEEN_CELL: "Es entsteht ein Plan: Stifte prüfen.",
|
||||
QUEEN: "Es entsteht ein Plan: Annahme der Königin prüfen.",
|
||||
UNKNOWN: "Ohne Angabe entsteht kein Plan.",
|
||||
"": "Ohne Angabe entsteht kein Plan.",
|
||||
};
|
||||
|
||||
function queenSourceOf(choice: QueenOriginChoice): SplitQueenSource | undefined {
|
||||
return choice === "" || choice === "UNKNOWN" ? undefined : choice;
|
||||
}
|
||||
|
||||
function SplitDialog({ colony }: { colony: Colony }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY");
|
||||
const [date, setDate] = useState(todayLocalIsoDate);
|
||||
// The origin of the queen is an observation: nothing is preselected.
|
||||
const [origin, setOrigin] = useState<QueenOriginChoice>("");
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => splitColony(colony.id, { code, colonyType }),
|
||||
mutationFn: () => {
|
||||
const queenSource = queenSourceOf(origin);
|
||||
return splitColony(colony.id, {
|
||||
code,
|
||||
colonyType,
|
||||
date,
|
||||
...(queenSource ? { queenSource } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateColony(queryClient, colony.id);
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
toast.success(`Ableger ${code} angelegt`);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (mutationError) =>
|
||||
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")),
|
||||
onError: (mutationError) => {
|
||||
// The date's own complaint belongs at the date field, not in the footer.
|
||||
if (mutationError instanceof ApiError && mutationError.status === 400 && /Datum/.test(mutationError.message)) {
|
||||
setDateError(mutationError.message);
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden."));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
@@ -163,6 +205,10 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
setCodeError("Bitte einen Code für den Ableger angeben.");
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
setDateError("Bitte ein Datum angeben.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
@@ -210,6 +256,41 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Datum des Ablegers" required error={dateError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
max={todayLocalIsoDate()}
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
setDateError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Woher kommt die Königin?" group>
|
||||
{({ id, labelId }) => (
|
||||
<>
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
options={QUEEN_ORIGIN_OPTIONS}
|
||||
value={origin}
|
||||
onChange={setOrigin}
|
||||
className="grid-cols-1 sm:grid-cols-2"
|
||||
/>
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{QUEEN_ORIGIN_CONSEQUENCE[origin]}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<ActionFooter error={error}>
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"}
|
||||
|
||||
@@ -21,6 +21,7 @@ const colony: Colony = {
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: 7,
|
||||
occupiedFrames: 8,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "AVERAGE",
|
||||
healthStatus: "CRITICAL",
|
||||
@@ -31,11 +32,11 @@ const colony: Colony = {
|
||||
deactivatedAt: null,
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
function renderCard(subject: Colony = colony) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ColonyEditCard colony={colony} />
|
||||
<ColonyEditCard colony={subject} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -85,7 +86,7 @@ describe("ColonyEditCard", () => {
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut");
|
||||
expect(screen.getByRole("combobox", { name: "Volksstärke" })).toHaveTextContent("7");
|
||||
expect(screen.getByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" })).toHaveTextContent("7");
|
||||
await choose(/^Status/, "Weisellos");
|
||||
await choose(/^Volktyp/, "Ableger");
|
||||
await choose(/Brutstatus/, "Kritisch");
|
||||
@@ -146,4 +147,85 @@ describe("ColonyEditCard", () => {
|
||||
notes: "sanft",
|
||||
});
|
||||
});
|
||||
|
||||
it("„Besetzte Waben“ accepts 0–40 and sends the number", async () => {
|
||||
renderCard();
|
||||
const field = screen.getByLabelText(/^Besetzte Waben/);
|
||||
expect(field).toHaveValue("8");
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "0");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: 0 });
|
||||
});
|
||||
|
||||
it("caps „Besetzte Waben“ at 40", async () => {
|
||||
renderCard();
|
||||
const field = screen.getByLabelText(/^Besetzte Waben/);
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "99");
|
||||
|
||||
expect(field).toHaveValue("40");
|
||||
});
|
||||
|
||||
it("clearing „Besetzte Waben“ sends null", async () => {
|
||||
renderCard();
|
||||
|
||||
await userEvent.clear(screen.getByLabelText(/^Besetzte Waben/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: null });
|
||||
});
|
||||
|
||||
it("shows the legacy strength field only when a value is stored", () => {
|
||||
renderCard({ ...colony, strength: null });
|
||||
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not touch an unset legacy strength when saving", async () => {
|
||||
renderCard({ ...colony, strength: null });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateColony).mock.calls[0][1]).not.toHaveProperty("strength");
|
||||
});
|
||||
|
||||
it("words the feed status like the inspection form", async () => {
|
||||
renderCard();
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Futterstatus" }));
|
||||
for (const name of ["Nicht erfasst", "Reichlich", "Ausreichend", "Mittel", "Niedrig", "Kritisch"]) {
|
||||
expect(await screen.findByRole("option", { name })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByRole("option", { name: "Sehr gut" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Gut" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColonyEditCard – legacy strength after clearing", () => {
|
||||
it("does not resend strength once it was cleared and the card shows the saved colony", async () => {
|
||||
vi.mocked(updateColony).mockReset().mockResolvedValue(colony);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const ui = (subject: Colony) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ColonyEditCard colony={subject} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(ui(colony));
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Volksstärke/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Nicht erfasst" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ strength: null });
|
||||
|
||||
rerender(ui({ ...colony, strength: null }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { updateColony } from "@/lib/colonies/api";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types";
|
||||
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
|
||||
import {
|
||||
COLONY_CONDITIONS,
|
||||
COLONY_CONDITION_LABELS,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
COLONY_STRENGTH_LABELS,
|
||||
COLONY_TYPES,
|
||||
COLONY_TYPE_LABELS,
|
||||
MAX_OCCUPIED_FRAMES,
|
||||
} from "@/lib/colonies/labels";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
@@ -25,6 +27,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -40,25 +43,28 @@ function ConditionSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
labels = COLONY_CONDITION_LABELS,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: ColonyCondition | null;
|
||||
onChange: (value: ColonyCondition | null) => void;
|
||||
/** How the five levels are worded; the feed scale has its own words. */
|
||||
labels?: Record<ColonyCondition, string>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<EnumSelect
|
||||
id={id}
|
||||
labels={COLONY_CONDITION_LABELS}
|
||||
labels={labels}
|
||||
values={COLONY_CONDITIONS}
|
||||
noneLabel={NOT_RECORDED_LABEL}
|
||||
value={value ?? ""}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
renderOption={(condition) => (
|
||||
<Badge className={COLONY_CONDITION_TONE_CLASS[condition]}>
|
||||
{COLONY_CONDITION_LABELS[condition]}
|
||||
{labels[condition]}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
@@ -74,6 +80,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
|
||||
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
|
||||
const [originText, setOriginText] = useState(colony.originText ?? "");
|
||||
const [occupiedFrames, setOccupiedFrames] = useState(colony.occupiedFrames?.toString() ?? "");
|
||||
const [strength, setStrength] = useState(colony.strength?.toString() ?? "");
|
||||
const [broodStatus, setBroodStatus] = useState<ColonyCondition | null>(colony.broodStatus);
|
||||
const [feedStatus, setFeedStatus] = useState<ColonyCondition | null>(colony.feedStatus);
|
||||
@@ -97,7 +104,9 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
// Emptied fields go out as null - omitted, the old value would stay.
|
||||
hiveType: emptyToNull(hiveType),
|
||||
originText: emptyToNull(originText),
|
||||
strength: emptyToNull(strength, Number),
|
||||
occupiedFrames: emptyToNull(occupiedFrames, Number),
|
||||
// The old 1-10 scale is only sent while a value is stored (to clear it).
|
||||
...(colony.strength !== null ? { strength: emptyToNull(strength, Number) } : {}),
|
||||
broodStatus,
|
||||
feedStatus,
|
||||
healthStatus,
|
||||
@@ -187,15 +196,29 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-strength">Volksstärke</Label>
|
||||
<EnumSelect
|
||||
id="colony-strength"
|
||||
labels={COLONY_STRENGTH_LABELS}
|
||||
noneLabel={NOT_RECORDED_LABEL}
|
||||
value={strength}
|
||||
onChange={setStrength}
|
||||
<Label htmlFor="colony-occupied-frames">Besetzte Waben</Label>
|
||||
<NumberStepper
|
||||
id="colony-occupied-frames"
|
||||
value={occupiedFrames}
|
||||
onChange={setOccupiedFrames}
|
||||
max={MAX_OCCUPIED_FRAMES}
|
||||
unitLabel="besetzte Waben"
|
||||
decrementLabel="Eine besetzte Wabe weniger"
|
||||
incrementLabel="Eine besetzte Wabe mehr"
|
||||
/>
|
||||
</div>
|
||||
{colony.strength !== null ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-strength">Volksstärke (alt, Skala 1–10)</Label>
|
||||
<EnumSelect
|
||||
id="colony-strength"
|
||||
labels={COLONY_STRENGTH_LABELS}
|
||||
noneLabel={NOT_RECORDED_LABEL}
|
||||
value={strength}
|
||||
onChange={setStrength}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ConditionSelect
|
||||
id="colony-brood"
|
||||
label="Brutstatus"
|
||||
@@ -207,6 +230,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
label="Futterstatus"
|
||||
value={feedStatus}
|
||||
onChange={setFeedStatus}
|
||||
labels={FEED_CONDITION_LABELS}
|
||||
/>
|
||||
<ConditionSelect
|
||||
id="colony-health"
|
||||
|
||||
@@ -38,7 +38,7 @@ const inspection: Inspection = {
|
||||
swarmMood: null,
|
||||
gentleness: 2,
|
||||
combBehavior: null,
|
||||
feedStatus: "ausreichend",
|
||||
feedStatus: "GOOD",
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
@@ -244,4 +244,69 @@ describe("InspectionEditDialog", () => {
|
||||
|
||||
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the edit dialog shows a stored AVERAGE feed value", async () => {
|
||||
vi.mocked(updateInspection).mockResolvedValue(inspection);
|
||||
await openDialog({ ...inspection, feedStatus: "AVERAGE" });
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: /^Futter/ });
|
||||
expect(
|
||||
within(group)
|
||||
.getAllByRole("radio")
|
||||
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent,
|
||||
).toBe("Mittel");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
const [, input] = vi.mocked(updateInspection).mock.calls[0];
|
||||
expect(input).toMatchObject({ feedStatus: "AVERAGE" });
|
||||
});
|
||||
|
||||
it("sends the feed status as the enum and never sends applyToColony on a correction", async () => {
|
||||
vi.mocked(updateInspection).mockResolvedValue(inspection);
|
||||
await openDialog();
|
||||
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", {
|
||||
name: "Niedrig",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
const [, input] = vi.mocked(updateInspection).mock.calls[0];
|
||||
expect(input).toMatchObject({ feedStatus: "WEAK" });
|
||||
expect(input).not.toHaveProperty("applyToColony");
|
||||
});
|
||||
|
||||
it("shows a hint for an unmappable stored feed text and does not overwrite it unless changed", async () => {
|
||||
vi.mocked(updateInspection).mockResolvedValue(inspection);
|
||||
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
|
||||
|
||||
expect(
|
||||
screen.getByText("Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(vi.mocked(updateInspection).mock.calls[0][1]).not.toHaveProperty("feedStatus");
|
||||
});
|
||||
|
||||
it("sends the new choice and drops the hint once the unmappable feed text is replaced", async () => {
|
||||
vi.mocked(updateInspection).mockResolvedValue(inspection);
|
||||
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
|
||||
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", { name: "Niedrig" }),
|
||||
);
|
||||
expect(screen.queryByText(/Alter Futterwert/)).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(vi.mocked(updateInspection).mock.calls[0][1]).toMatchObject({ feedStatus: "WEAK" });
|
||||
});
|
||||
|
||||
it("caps „Volksstärke“ at 40 besetzte Waben", async () => {
|
||||
await openDialog();
|
||||
const field = screen.getByLabelText(/^Volksstärke/);
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "77");
|
||||
|
||||
expect(field).toHaveValue("40");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateInspection } from "@/lib/inspections/api";
|
||||
import { Inspection } from "@/lib/inspections/types";
|
||||
import { feedConditionFromInput } from "@/lib/colonies/condition";
|
||||
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import type { RatingDirection } from "@/lib/forms/rating";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -24,9 +26,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
FEED_STATUS_OPTIONS,
|
||||
INSPECTION_OBSERVATIONS,
|
||||
InspectionFormValues,
|
||||
feedFieldFrom,
|
||||
feedLegacyHint,
|
||||
feedStatusOptions,
|
||||
toInspectionInput,
|
||||
} from "../../inspections/new/inspection-form-values";
|
||||
import {
|
||||
@@ -55,14 +59,17 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
|
||||
swarmMood: inspection.swarmMood,
|
||||
gentleness: inspection.gentleness,
|
||||
combBehavior: inspection.combBehavior,
|
||||
// Kept verbatim even if it is not one of the three known values, so
|
||||
// saving without touching it never changes it.
|
||||
feedStatus: inspection.feedStatus,
|
||||
// Any stored level is kept (the colony scale has five), so saving
|
||||
// without touching it never changes it.
|
||||
// An unmappable stored text stays untouched (and is hinted at) until changed.
|
||||
...feedFieldFrom(inspection.feedStatus),
|
||||
honeySuperStatus: inspection.honeySuperStatus ?? "",
|
||||
healthIssues: inspection.healthIssues ?? "",
|
||||
varroaIssues: inspection.varroaIssues ?? "",
|
||||
measures: inspection.measures ?? "",
|
||||
notes: inspection.notes ?? "",
|
||||
// A correction never takes anything over onto the colony card.
|
||||
applyToColony: [],
|
||||
weatherAuto: false,
|
||||
// A saved inspection takes its photos straight to the server (Fotos below).
|
||||
photoSessionId: "",
|
||||
@@ -96,12 +103,18 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
);
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
// Offered from the stored value, so a level outside the three stays visible.
|
||||
const [feedOptions] = useState(() => feedStatusOptions(feedConditionFromInput(inspection.feedStatus)));
|
||||
|
||||
function set<K extends keyof InspectionFormValues>(
|
||||
key: K,
|
||||
value: InspectionFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === "feedStatus" ? { feedLegacyText: null } : {}),
|
||||
}));
|
||||
if (key === "date") setDateError(null);
|
||||
}
|
||||
|
||||
@@ -240,6 +253,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
id={id}
|
||||
value={values.colonyStrength}
|
||||
onChange={(count) => set("colonyStrength", count)}
|
||||
max={MAX_OCCUPIED_FRAMES}
|
||||
unitLabel="besetzte Waben"
|
||||
decrementLabel="Eine besetzte Wabe weniger"
|
||||
incrementLabel="Eine besetzte Wabe mehr"
|
||||
@@ -267,14 +281,21 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
<FormSection step={4} title="Futter">
|
||||
<Field label="Futterstatus" group>
|
||||
{({ id, labelId }) => (
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
options={FEED_STATUS_OPTIONS}
|
||||
value={values.feedStatus}
|
||||
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
||||
className="grid-cols-2 sm:grid-cols-4"
|
||||
/>
|
||||
<>
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
options={feedOptions}
|
||||
value={values.feedStatus}
|
||||
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
||||
className="grid-cols-2 sm:grid-cols-4"
|
||||
/>
|
||||
{values.feedLegacyText !== null ? (
|
||||
<p role="status" className="text-sm">
|
||||
{feedLegacyHint(values.feedLegacyText)}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ColonyDetailPage from "./page";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ id: "colony-1" }),
|
||||
@@ -14,6 +15,16 @@ vi.mock("@/lib/export/api", () => ({
|
||||
exportColonyRecordCardPdf: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
|
||||
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
|
||||
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
|
||||
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
|
||||
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
|
||||
vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null }));
|
||||
vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null }));
|
||||
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
|
||||
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
|
||||
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -48,3 +59,41 @@ describe("ColonyDetailPage – failed load", () => {
|
||||
expect(fetchColony).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColonyDetailPage – strength", () => {
|
||||
const colony = {
|
||||
id: "colony-1",
|
||||
code: "V-01",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
} as Colony;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColony).mockReset();
|
||||
});
|
||||
|
||||
it("shows „{n} besetzte Waben“", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue({ ...colony, occupiedFrames: 8, strength: 7 });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("8 besetzte Waben")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Volksstärke \(alt\)/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the colony page shows „Volksstärke (alt): 7 von 10“ without occupiedFrames", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue({ ...colony, strength: 7 });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Volksstärke (alt): 7 von 10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no strength line when none is stored", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "V-01" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/besetzte Waben|Volksstärke/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ClipboardCheck, FileText, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
|
||||
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
@@ -46,6 +46,8 @@ export default function ColonyDetailPage() {
|
||||
return <DetailQueryFallback query={colonyQuery} what="Volksdaten" {...BACK} />;
|
||||
}
|
||||
|
||||
const strengthText = colonyStrengthText(colony);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
@@ -81,6 +83,7 @@ export default function ColonyDetailPage() {
|
||||
]}
|
||||
{...BACK}
|
||||
/>
|
||||
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ColonyFormValues {
|
||||
colonyType: ColonyType;
|
||||
hiveType: string;
|
||||
originText: string;
|
||||
/** Occupied frames as typed; "" = not counted. */
|
||||
occupiedFrames: string;
|
||||
}
|
||||
|
||||
export type ColonyFormErrors = Partial<Record<keyof ColonyFormValues, string>>;
|
||||
@@ -19,6 +21,7 @@ export function initialColonyValues(): ColonyFormValues {
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "",
|
||||
originText: "",
|
||||
occupiedFrames: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,5 +41,6 @@ export function toColonyInput(values: ColonyFormValues): CreateColonyInput {
|
||||
// Not filled in = null, the same convention as every edit form.
|
||||
hiveType: emptyToNull(values.hiveType),
|
||||
originText: emptyToNull(values.originText),
|
||||
occupiedFrames: emptyToNull(values.occupiedFrames, Number),
|
||||
};
|
||||
}
|
||||
@@ -60,6 +60,7 @@ describe("NewColonyPage", () => {
|
||||
// Not filled in = null, the same convention as every edit form.
|
||||
hiveType: null,
|
||||
originText: null,
|
||||
occupiedFrames: null,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-9");
|
||||
});
|
||||
@@ -76,3 +77,39 @@ describe("NewColonyPage", () => {
|
||||
expect(createColony).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NewColonyPage – besetzte Waben", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(fetchApiaries).mockResolvedValue([
|
||||
{ id: "apiary-1", name: "Hausgarten", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createColony).mockReset();
|
||||
vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never);
|
||||
});
|
||||
|
||||
it("sends only occupiedFrames, no old strength", async () => {
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Code/), "V-08");
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
|
||||
await userEvent.type(screen.getByLabelText(/^Besetzte Waben/), "6");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
|
||||
|
||||
const [input] = vi.mocked(createColony).mock.calls[0];
|
||||
expect(input).toMatchObject({ occupiedFrames: 6 });
|
||||
expect(input).not.toHaveProperty("strength");
|
||||
});
|
||||
|
||||
it("caps the count at 40", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
|
||||
const field = screen.getByLabelText(/^Besetzte Waben/);
|
||||
|
||||
await userEvent.type(field, "63");
|
||||
|
||||
expect(field).toHaveValue("40");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Layers } from "lucide-react";
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -85,7 +86,8 @@ export default function NewColonyPage() {
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
// A draft from before „Besetzte Waben“ existed lacks it.
|
||||
setValues({ ...initialColonyValues(), ...restored });
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,7 @@ export default function NewColonyPage() {
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={2}
|
||||
fieldCount={3}
|
||||
>
|
||||
<Field label="Beutentyp" hint="z. B. Zander, Dadant">
|
||||
{({ id, describedBy }) => (
|
||||
@@ -195,6 +197,20 @@ export default function NewColonyPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Besetzte Waben" hint="Wie viele Waben das Volk besetzt">
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={values.occupiedFrames}
|
||||
onChange={(count) => set("occupiedFrames", count)}
|
||||
max={MAX_OCCUPIED_FRAMES}
|
||||
unitLabel="besetzte Waben"
|
||||
decrementLabel="Eine besetzte Wabe weniger"
|
||||
incrementLabel="Eine besetzte Wabe mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("ColoniesPage", () => {
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { Colony, ColonyStatus } from "@/lib/colonies/types";
|
||||
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import {
|
||||
COLONY_CONDITION_LABELS,
|
||||
COLONY_STATUS_LABELS,
|
||||
COLONY_TYPE_LABELS,
|
||||
colonyStrengthText,
|
||||
} from "@/lib/colonies/labels";
|
||||
import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status";
|
||||
import { exportColoniesCsv } from "@/lib/export/api";
|
||||
import { statusTone } from "@/lib/status-tone";
|
||||
@@ -175,7 +180,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
|
||||
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Stärke", value: colony.strength ?? "—" },
|
||||
{ label: "Stärke", value: colonyStrengthText(colony) ?? "—" },
|
||||
{
|
||||
label: "Gesundheit",
|
||||
value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { initialInspectionValues, toInspectionInput } from "./inspection-form-values";
|
||||
import {
|
||||
currentDraftValues,
|
||||
feedLegacyHint,
|
||||
feedStatusOptions,
|
||||
initialInspectionValues,
|
||||
syncTakeoverSelection,
|
||||
takeoverFieldsFilled,
|
||||
toCreateInspectionInput,
|
||||
toInspectionInput,
|
||||
} from "./inspection-form-values";
|
||||
|
||||
const OBSERVATIONS = [
|
||||
"queenSeen",
|
||||
@@ -38,14 +47,14 @@ describe("inspection form values", () => {
|
||||
queenSeen: false,
|
||||
eggsPresent: false,
|
||||
queenCellsPresent: true,
|
||||
feedStatus: "kritisch",
|
||||
feedStatus: "CRITICAL",
|
||||
});
|
||||
|
||||
expect(input).toMatchObject({
|
||||
queenSeen: false,
|
||||
eggsPresent: false,
|
||||
queenCellsPresent: true,
|
||||
feedStatus: "kritisch",
|
||||
feedStatus: "CRITICAL",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,4 +143,141 @@ describe("inspection form values", () => {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the feed status as the enum, also for a legacy word left in a draft", () => {
|
||||
const base = initialInspectionValues("colony-1");
|
||||
|
||||
expect(toInspectionInput({ ...base, feedStatus: "WEAK" }).feedStatus).toBe("WEAK");
|
||||
expect(
|
||||
toInspectionInput({ ...base, feedStatus: "niedrig" as never }).feedStatus,
|
||||
).toBe("WEAK");
|
||||
});
|
||||
|
||||
it("never sends applyToColony on a correction", () => {
|
||||
const values = { ...initialInspectionValues("colony-1"), applyToColony: ["feedStatus" as const] };
|
||||
|
||||
expect(toInspectionInput(values)).not.toHaveProperty("applyToColony");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Volkskarte selection", () => {
|
||||
const blank = () => initialInspectionValues("colony-1");
|
||||
|
||||
it("starts empty and lists exactly the fields filled in", () => {
|
||||
expect(blank().applyToColony).toEqual([]);
|
||||
expect(takeoverFieldsFilled(blank())).toEqual([]);
|
||||
expect(
|
||||
takeoverFieldsFilled({ ...blank(), colonyStrength: "0", broodPattern: 2, feedStatus: "WEAK" }),
|
||||
).toEqual(["occupiedFrames", "broodStatus", "feedStatus"]);
|
||||
});
|
||||
|
||||
it("checks a field when it gets filled and unchecks it when it is emptied", () => {
|
||||
const filled = syncTakeoverSelection(blank(), { ...blank(), feedStatus: "WEAK" });
|
||||
expect(filled.applyToColony).toEqual(["feedStatus"]);
|
||||
|
||||
const emptied = syncTakeoverSelection(filled, { ...filled, feedStatus: null });
|
||||
expect(emptied.applyToColony).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the order of the fields and leaves a manual uncheck alone", () => {
|
||||
const before = { ...blank(), feedStatus: "WEAK" as const, applyToColony: [] };
|
||||
|
||||
const after = syncTakeoverSelection(before, { ...before, colonyStrength: "9" });
|
||||
|
||||
expect(after.applyToColony).toEqual(["occupiedFrames"]);
|
||||
expect(syncTakeoverSelection(after, { ...after, notes: "x" }).applyToColony).toEqual([
|
||||
"occupiedFrames",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds the create input with the checked fields that are filled in", () => {
|
||||
const values = {
|
||||
...blank(),
|
||||
colonyStrength: "9",
|
||||
feedStatus: "WEAK" as const,
|
||||
applyToColony: ["occupiedFrames" as const, "feedStatus" as const, "broodStatus" as const],
|
||||
};
|
||||
|
||||
expect(toCreateInspectionInput(values, true).applyToColony).toEqual([
|
||||
"occupiedFrames",
|
||||
"feedStatus",
|
||||
]);
|
||||
expect(toCreateInspectionInput(values, false)).not.toHaveProperty("applyToColony");
|
||||
expect(
|
||||
toCreateInspectionInput({ ...values, applyToColony: [] }, true),
|
||||
).not.toHaveProperty("applyToColony");
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedStatusOptions", () => {
|
||||
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
|
||||
feedStatusOptions(stored).map((option) => option.label);
|
||||
|
||||
it("offers three levels plus „Nicht geprüft“", () => {
|
||||
expect(labels(null)).toEqual(["Ausreichend", "Niedrig", "Kritisch", "Nicht geprüft"]);
|
||||
});
|
||||
|
||||
it("also shows a stored value outside the three", () => {
|
||||
expect(labels("AVERAGE")).toEqual([
|
||||
"Ausreichend",
|
||||
"Niedrig",
|
||||
"Kritisch",
|
||||
"Mittel",
|
||||
"Nicht geprüft",
|
||||
]);
|
||||
expect(labels("EXCELLENT")).toContain("Reichlich");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Volkskarte selection – never ticked by copied values", () => {
|
||||
const blank = () => initialInspectionValues("colony-1");
|
||||
|
||||
it("does not tick a field that was filled by copying the previous inspection", () => {
|
||||
const copied = syncTakeoverSelection(blank(), { ...blank(), feedStatus: "WEAK" }, { autoTick: false });
|
||||
|
||||
expect(copied.applyToColony).toEqual([]);
|
||||
});
|
||||
|
||||
it("still unticks a field that is emptied again (undo)", () => {
|
||||
const ticked = { ...blank(), feedStatus: "WEAK" as const, applyToColony: ["feedStatus" as const] };
|
||||
|
||||
expect(
|
||||
syncTakeoverSelection(ticked, { ...ticked, feedStatus: null }, { autoTick: false }).applyToColony,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ticks a touched field even if it was filled before", () => {
|
||||
const before = { ...blank(), colonyStrength: "8" };
|
||||
|
||||
const after = syncTakeoverSelection(before, { ...before, colonyStrength: "9" }, { touched: "occupiedFrames" });
|
||||
|
||||
expect(after.applyToColony).toEqual(["occupiedFrames"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmappable legacy feed text", () => {
|
||||
it("leaves the field empty, keeps the text for a hint and does not send null", () => {
|
||||
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "halb voll" as never });
|
||||
|
||||
expect(values.feedStatus).toBeNull();
|
||||
expect(values.feedLegacyText).toBe("halb voll");
|
||||
expect(toInspectionInput(values)).not.toHaveProperty("feedStatus");
|
||||
expect(feedLegacyHint("halb voll")).toBe(
|
||||
"Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen.",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the new choice once the user picks one, and null when they pick „Nicht geprüft“", () => {
|
||||
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "halb voll" as never });
|
||||
|
||||
expect(toInspectionInput({ ...values, feedStatus: "WEAK", feedLegacyText: null }).feedStatus).toBe("WEAK");
|
||||
expect(toInspectionInput({ ...values, feedLegacyText: null })).toHaveProperty("feedStatus", null);
|
||||
});
|
||||
|
||||
it("a legacy draft without selection ticks nothing by itself", () => {
|
||||
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "niedrig" as never, colonyStrength: "8" });
|
||||
|
||||
expect(values.feedStatus).toBe("WEAK");
|
||||
expect(values.applyToColony).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,59 @@
|
||||
import type { ChoiceOption } from "@/components/form/choice-group";
|
||||
import type { createInspection } from "@/lib/inspections/api";
|
||||
import {
|
||||
FEED_CONDITION_LABELS,
|
||||
INSPECTION_FEED_OPTIONS,
|
||||
feedConditionFromInput,
|
||||
ratingToCondition,
|
||||
} from "@/lib/colonies/condition";
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import {
|
||||
COLONY_TAKEOVER_FIELDS,
|
||||
type ColonyTakeoverField,
|
||||
type CreateInspectionInput,
|
||||
} from "@/lib/inspections/types";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
|
||||
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
|
||||
const NOT_CHECKED: ChoiceOption<ColonyCondition | null> = { value: null, label: "Nicht geprüft" };
|
||||
|
||||
/** The three feed levels plus "Nicht geprüft" (null), which is the default. */
|
||||
export const FEED_STATUS_OPTIONS: readonly ChoiceOption<FeedStatus | null>[] = [
|
||||
{ value: "ausreichend", label: "Ausreichend" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "kritisch", label: "Kritisch" },
|
||||
{ value: null, label: "Nicht geprüft" },
|
||||
];
|
||||
/**
|
||||
* The three feed levels plus "Nicht geprüft" (null), which is the default.
|
||||
* A stored value outside the three (the colony scale has five) is shown too,
|
||||
* so an edit never hides what an inspection recorded.
|
||||
*/
|
||||
export function feedStatusOptions(
|
||||
stored: ColonyCondition | null = null,
|
||||
): readonly ChoiceOption<ColonyCondition | null>[] {
|
||||
const levels: ColonyCondition[] = [...INSPECTION_FEED_OPTIONS];
|
||||
if (stored !== null && !levels.includes(stored)) {
|
||||
levels.push(stored);
|
||||
}
|
||||
return [
|
||||
...levels.map((value) => ({ value, label: FEED_CONDITION_LABELS[value] })),
|
||||
NOT_CHECKED,
|
||||
];
|
||||
}
|
||||
|
||||
export const FEED_STATUS_OPTIONS = feedStatusOptions();
|
||||
|
||||
/**
|
||||
* A feed answer from an older app version, split into what can be used and
|
||||
* what cannot: a legacy word becomes the enum, free text that maps to nothing
|
||||
* is kept aside so the form can say so instead of dropping it silently.
|
||||
*/
|
||||
export function feedFieldFrom(raw: string | null | undefined): {
|
||||
feedStatus: ColonyCondition | null;
|
||||
feedLegacyText: string | null;
|
||||
} {
|
||||
const feedStatus = feedConditionFromInput(raw);
|
||||
const text = raw?.trim() ?? "";
|
||||
return { feedStatus, feedLegacyText: feedStatus === null && text !== "" ? text : null };
|
||||
}
|
||||
|
||||
export function feedLegacyHint(text: string): string {
|
||||
return `Alter Futterwert „${text}“ nicht übernommen – bitte neu wählen.`;
|
||||
}
|
||||
|
||||
/** The yes/no observations of an inspection, in the order they are asked. */
|
||||
export const INSPECTION_OBSERVATIONS = [
|
||||
@@ -47,17 +88,26 @@ export interface InspectionFormValues {
|
||||
swarmMood: number | null;
|
||||
gentleness: number | null;
|
||||
combBehavior: number | null;
|
||||
/** A feed level, or null for "Nicht geprüft". */
|
||||
feedStatus: ColonyCondition | null;
|
||||
/**
|
||||
* One of FEED_STATUS_OPTIONS, or null for "Nicht geprüft" - or an older
|
||||
* free-text value, kept verbatim so saving without touching it changes
|
||||
* nothing.
|
||||
* An old feed text that maps to no level: the field stays empty, the text
|
||||
* is shown as a hint, and the stored value is not overwritten with null
|
||||
* unless the beekeeper changes the field. Never sent.
|
||||
*/
|
||||
feedStatus: string | null;
|
||||
feedLegacyText: string | null;
|
||||
honeySuperStatus: string;
|
||||
healthIssues: string;
|
||||
varroaIssues: string;
|
||||
measures: string;
|
||||
notes: string;
|
||||
/**
|
||||
* The colony-card fields to take over from this inspection (Volkskarte).
|
||||
* Follows what is filled in: a field is checked when it gets a value and
|
||||
* unchecked when it is emptied; unchecking by hand stays until the field
|
||||
* is emptied and filled again. Create only - never sent on a correction.
|
||||
*/
|
||||
applyToColony: ColonyTakeoverField[];
|
||||
/**
|
||||
* true: weather and temperature come from the apiary weather prefill (or
|
||||
* are still empty) and may be replaced when date or colony change. Any
|
||||
@@ -92,11 +142,13 @@ export function initialInspectionValues(
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: null,
|
||||
feedLegacyText: null,
|
||||
honeySuperStatus: "",
|
||||
healthIssues: "",
|
||||
varroaIssues: "",
|
||||
measures: "",
|
||||
notes: "",
|
||||
applyToColony: [],
|
||||
weatherAuto: true,
|
||||
photoSessionId: newIdempotencyKey(),
|
||||
};
|
||||
@@ -110,7 +162,7 @@ export function initialInspectionValues(
|
||||
*/
|
||||
export function toInspectionInput(
|
||||
values: InspectionFormValues,
|
||||
): Parameters<typeof createInspection>[0] {
|
||||
): Omit<CreateInspectionInput, "applyToColony"> {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: toApiDate(values.date),
|
||||
@@ -128,7 +180,11 @@ export function toInspectionInput(
|
||||
swarmMood: values.swarmMood ?? null,
|
||||
gentleness: values.gentleness ?? null,
|
||||
combBehavior: values.combBehavior ?? null,
|
||||
feedStatus: values.feedStatus,
|
||||
// A legacy word left in an old draft still goes out as the enum. While an
|
||||
// unmappable old text is kept, nothing is sent: null would erase it.
|
||||
...(values.feedLegacyText !== null && values.feedStatus === null
|
||||
? {}
|
||||
: { feedStatus: feedConditionFromInput(values.feedStatus) }),
|
||||
honeySuperStatus: emptyToNull(values.honeySuperStatus),
|
||||
healthIssues: emptyToNull(values.healthIssues),
|
||||
varroaIssues: emptyToNull(values.varroaIssues),
|
||||
@@ -136,3 +192,87 @@ export function toInspectionInput(
|
||||
notes: emptyToNull(values.notes),
|
||||
};
|
||||
}
|
||||
|
||||
type TakeoverSource = Pick<InspectionFormValues, "colonyStrength" | "broodPattern" | "feedStatus">;
|
||||
|
||||
const IS_FILLED: Record<ColonyTakeoverField, (values: TakeoverSource) => boolean> = {
|
||||
occupiedFrames: (values) => values.colonyStrength.trim() !== "",
|
||||
broodStatus: (values) => ratingToCondition(values.broodPattern) !== null,
|
||||
feedStatus: (values) => values.feedStatus !== null,
|
||||
};
|
||||
|
||||
/** The Volkskarte fields this inspection has a value for, in the server's order. */
|
||||
export function takeoverFieldsFilled(values: TakeoverSource): ColonyTakeoverField[] {
|
||||
return COLONY_TAKEOVER_FIELDS.filter((field) => IS_FILLED[field](values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the Volkskarte selection in step with the form: a field that just
|
||||
* got a value is checked, one that was just emptied is unchecked, and
|
||||
* everything else - a box unchecked by hand - stays as it is.
|
||||
*/
|
||||
export function syncTakeoverSelection(
|
||||
before: InspectionFormValues,
|
||||
after: InspectionFormValues,
|
||||
options: {
|
||||
/** false: a newly filled field is not ticked (values copied from the last inspection). */
|
||||
autoTick?: boolean;
|
||||
/** The field the beekeeper just typed or tapped: ticked when it has a value. */
|
||||
touched?: ColonyTakeoverField;
|
||||
} = {},
|
||||
): InspectionFormValues {
|
||||
const { autoTick = true, touched } = options;
|
||||
const was = new Set(takeoverFieldsFilled(before));
|
||||
const now = new Set(takeoverFieldsFilled(after));
|
||||
const selected = new Set(after.applyToColony);
|
||||
for (const field of COLONY_TAKEOVER_FIELDS) {
|
||||
if (now.has(field) && (field === touched || (autoTick && !was.has(field)))) selected.add(field);
|
||||
if (!now.has(field) && was.has(field)) selected.delete(field);
|
||||
}
|
||||
const next = COLONY_TAKEOVER_FIELDS.filter((field) => selected.has(field));
|
||||
const unchanged =
|
||||
next.length === after.applyToColony.length &&
|
||||
next.every((field, index) => field === after.applyToColony[index]);
|
||||
return unchanged ? after : { ...after, applyToColony: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new inspection sends: the correction input plus, when the beekeeper
|
||||
* may update colonies, the checked Volkskarte fields that have a value.
|
||||
* Omitted when empty, so nothing is asked of the server that was not wanted.
|
||||
*/
|
||||
export function toCreateInspectionInput(
|
||||
values: InspectionFormValues,
|
||||
canUpdateColony: boolean,
|
||||
): CreateInspectionInput {
|
||||
const input = toInspectionInput(values);
|
||||
if (!canUpdateColony) {
|
||||
return input;
|
||||
}
|
||||
const filled = new Set(takeoverFieldsFilled(values));
|
||||
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
|
||||
return applyToColony.length > 0 ? { ...input, applyToColony } : input;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form values of a restored draft, on top of a fresh form. A draft saved
|
||||
* by an earlier app version may hold a legacy feed word and has no Volkskarte
|
||||
* selection yet - that then starts empty. `extra`
|
||||
* wins over the draft (the form's own photo session, weather mode).
|
||||
*/
|
||||
export function currentDraftValues(
|
||||
restored: Partial<InspectionFormValues> & Pick<InspectionFormValues, "colonyId">,
|
||||
extra: Partial<InspectionFormValues> = {},
|
||||
): InspectionFormValues {
|
||||
const merged = { ...initialInspectionValues(restored.colonyId), ...restored, ...extra };
|
||||
const feed =
|
||||
merged.feedStatus !== null
|
||||
? feedFieldFrom(merged.feedStatus)
|
||||
: { feedStatus: null, feedLegacyText: merged.feedLegacyText ?? null };
|
||||
return {
|
||||
...merged,
|
||||
...feed,
|
||||
// Nothing is ticked on the draft's behalf: an old draft cannot tell typed from copied.
|
||||
applyToColony: Array.isArray(restored.applyToColony) ? restored.applyToColony : [],
|
||||
};
|
||||
}
|
||||
@@ -216,6 +216,235 @@ describe("NewInspectionPage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("Volkskarte", () => {
|
||||
const colonyCard = {
|
||||
id: "colony-1",
|
||||
code: "V-01",
|
||||
status: "ACTIVE",
|
||||
apiaryId: "apiary-1",
|
||||
occupiedFrames: 6,
|
||||
broodStatus: "AVERAGE",
|
||||
feedStatus: "GOOD",
|
||||
};
|
||||
const SECTION = { name: /Volkskarte/ };
|
||||
|
||||
beforeEach(() => {
|
||||
permissions = new Set(["colonies:update"]);
|
||||
vi.mocked(fetchColonies).mockResolvedValue([colonyCard] as never);
|
||||
vi.mocked(createInspection).mockResolvedValue({
|
||||
id: "i-1",
|
||||
colonyId: "colony-1",
|
||||
colonyTakeover: { applied: ["feedStatus"], skipped: [] },
|
||||
} as never);
|
||||
});
|
||||
|
||||
async function pickFeed(choice: string) {
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ })).getByRole("radio", {
|
||||
name: choice,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||
}
|
||||
|
||||
it("checks only fields filled in this inspection and shows „bisher → neu“", async () => {
|
||||
renderPage();
|
||||
expect(screen.queryByRole("group", SECTION)).not.toBeInTheDocument();
|
||||
|
||||
await pickFeed("Niedrig");
|
||||
|
||||
const section = await screen.findByRole("group", SECTION);
|
||||
expect(
|
||||
within(section).getByRole("checkbox", { name: "Futter: Ausreichend → Niedrig" }),
|
||||
).toBeChecked();
|
||||
expect(within(section).getAllByRole("checkbox")).toHaveLength(1);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^Volksstärke/), "9");
|
||||
expect(
|
||||
await within(section).findByRole("checkbox", { name: "Besetzte Waben: 6 → 9" }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("sits directly above the save button", async () => {
|
||||
renderPage();
|
||||
await pickFeed("Niedrig");
|
||||
|
||||
const section = await screen.findByRole("group", SECTION);
|
||||
const save = screen.getByRole("button", { name: "Durchsicht speichern" });
|
||||
|
||||
expect(
|
||||
section.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the section without colonies:update", async () => {
|
||||
permissions = new Set();
|
||||
renderPage();
|
||||
|
||||
await pickFeed("Niedrig");
|
||||
|
||||
expect(screen.queryByRole("group", SECTION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends feedStatus as WEAK and applyToColony with the checked fields", async () => {
|
||||
renderPage();
|
||||
await pickFeed("Niedrig");
|
||||
await userEvent.type(screen.getByLabelText(/^Volksstärke/), "9");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("checkbox", { name: "Besetzte Waben: 6 → 9" }),
|
||||
);
|
||||
await save();
|
||||
|
||||
const [input] = vi.mocked(createInspection).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
feedStatus: "WEAK",
|
||||
colonyStrength: 9,
|
||||
applyToColony: ["feedStatus"],
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no applyToColony without the permission or without filled fields", async () => {
|
||||
renderPage();
|
||||
await save();
|
||||
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("applyToColony");
|
||||
});
|
||||
|
||||
it("tells that the card was not updated when a newer inspection exists", async () => {
|
||||
vi.mocked(createInspection).mockResolvedValue({
|
||||
id: "i-1",
|
||||
colonyId: "colony-1",
|
||||
colonyTakeover: {
|
||||
applied: [],
|
||||
skipped: [{ field: "feedStatus", reason: "NEWER_INSPECTION" }],
|
||||
},
|
||||
} as never);
|
||||
renderPage();
|
||||
await pickFeed("Niedrig");
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
"Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays quiet about the card when everything was taken over", async () => {
|
||||
renderPage();
|
||||
await pickFeed("Niedrig");
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalled());
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps applyToColony in an offline-queued inspection", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderPage();
|
||||
await pickFeed("Kritisch");
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
|
||||
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ feedStatus: "CRITICAL", applyToColony: ["feedStatus"] }),
|
||||
expect.any(String),
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("caps „Volksstärke“ at 40 besetzte Waben", async () => {
|
||||
renderPage();
|
||||
const field = screen.getByLabelText(/^Volksstärke/);
|
||||
|
||||
await userEvent.type(field, "55");
|
||||
|
||||
expect(field).toHaveValue("40");
|
||||
});
|
||||
|
||||
it("restores a draft of the previous version with its legacy feed word as the enum", async () => {
|
||||
// A draft of the previous version: no applyToColony, a legacy feed word.
|
||||
const { feedStatus, applyToColony, ...rest } = initialInspectionValues("colony-1");
|
||||
void feedStatus;
|
||||
void applyToColony;
|
||||
window.localStorage.setItem(
|
||||
`${DRAFT_PREFIX}user-1:inspection-new`,
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
values: { ...rest, feedStatus: "niedrig", colonyStrength: "8" },
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ }))
|
||||
.getAllByRole("radio")
|
||||
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent,
|
||||
).toBe("Niedrig");
|
||||
// Nothing is ticked on the draft's behalf.
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "Besetzte Waben: 6 → 8" }),
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Futter: Ausreichend → Niedrig" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("shows a hint for unmappable legacy feed text in a restored draft and sends no feedStatus", async () => {
|
||||
const { feedStatus, applyToColony, ...rest } = initialInspectionValues("colony-1");
|
||||
void feedStatus;
|
||||
void applyToColony;
|
||||
window.localStorage.setItem(
|
||||
`${DRAFT_PREFIX}user-1:inspection-new`,
|
||||
JSON.stringify({ savedAt: Date.now(), values: { ...rest, feedStatus: "halb voll" } }),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
|
||||
|
||||
expect(
|
||||
screen.getByText("Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen."),
|
||||
).toBeInTheDocument();
|
||||
await save();
|
||||
|
||||
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("feedStatus");
|
||||
});
|
||||
|
||||
it("does not tick fields copied with „Alles wie letztes Mal“ or „übernehmen“, but ticks what is typed", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockResolvedValue([
|
||||
{
|
||||
id: "i-0",
|
||||
colonyId: "colony-1",
|
||||
date: "2026-10-01T00:00:00.000Z",
|
||||
colonyStrength: 8,
|
||||
feedStatus: "WEAK",
|
||||
broodPattern: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
},
|
||||
] as never);
|
||||
renderPage();
|
||||
await screen.findByText(/Letzte Durchsicht:/);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alles wie letztes Mal" }));
|
||||
|
||||
const section = await screen.findByRole("group", SECTION);
|
||||
for (const box of within(section).getAllByRole("checkbox")) {
|
||||
expect(box).not.toBeChecked();
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ })).getByRole("radio", { name: "Kritisch" }),
|
||||
);
|
||||
expect(within(section).getByRole("checkbox", { name: /^Futter:/ })).toBeChecked();
|
||||
expect(within(section).getByRole("checkbox", { name: /^Besetzte Waben:/ })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture helpers", () => {
|
||||
const lastInspection: Inspection = {
|
||||
id: "i-0",
|
||||
@@ -236,7 +465,7 @@ describe("NewInspectionPage", () => {
|
||||
swarmMood: 4,
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: "niedrig",
|
||||
feedStatus: "WEAK",
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
@@ -425,7 +654,7 @@ describe("NewInspectionPage", () => {
|
||||
daily: [dayWeather(todayLocalIsoDate())],
|
||||
});
|
||||
renderPage();
|
||||
const live = await screen.findByRole("status");
|
||||
const live = await screen.findByTestId("weather-notice");
|
||||
expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
|
||||
|
||||
@@ -442,7 +671,7 @@ describe("NewInspectionPage", () => {
|
||||
daily: [dayWeather(todayLocalIsoDate()), dayWeather(other, { weatherCode: 61, tempMaxC: 14 })],
|
||||
});
|
||||
renderPage();
|
||||
const live = await screen.findByRole("status");
|
||||
const live = await screen.findByTestId("weather-notice");
|
||||
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
|
||||
const first = live.textContent;
|
||||
|
||||
@@ -458,14 +687,14 @@ describe("NewInspectionPage", () => {
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
|
||||
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
expect(screen.getByTestId("weather-notice")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("keeps the weather announcement region present while it is empty", () => {
|
||||
renderPage();
|
||||
|
||||
// Hidden while empty, it would swallow the first announcement.
|
||||
const live = screen.getByRole("status");
|
||||
const live = screen.getByTestId("weather-notice");
|
||||
expect(live).toBeEmptyDOMElement();
|
||||
expect(hidesWhileEmpty(live)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
previousLabel,
|
||||
previousValueOf,
|
||||
takeOverAll,
|
||||
takeoverSkipNotice,
|
||||
undoTakeOver,
|
||||
type TakeoverField,
|
||||
} from "@/lib/inspections/takeover";
|
||||
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||
import type { CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY, useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
@@ -50,6 +52,7 @@ import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { TriStateField } from "@/components/form/tri-state-field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { PreviousValueHint } from "@/components/form/previous-value-hint";
|
||||
import { ColonyTakeoverSection } from "@/components/colonies/colony-takeover-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
|
||||
@@ -57,8 +60,11 @@ import {
|
||||
FEED_STATUS_OPTIONS,
|
||||
INSPECTION_OBSERVATIONS,
|
||||
InspectionFormValues,
|
||||
currentDraftValues,
|
||||
feedLegacyHint,
|
||||
initialInspectionValues,
|
||||
toInspectionInput,
|
||||
syncTakeoverSelection,
|
||||
toCreateInspectionInput,
|
||||
} from "./inspection-form-values";
|
||||
|
||||
const UNDO_TOAST_MS = 8000;
|
||||
@@ -82,6 +88,13 @@ const TAKEOVER_LABELS: Record<TakeoverField, string> = {
|
||||
honeySuperStatus: "Honigraum",
|
||||
};
|
||||
|
||||
/** The Volkskarte field an answer feeds, for ticking it when the beekeeper edits it. */
|
||||
const TOUCHED_TAKEOVER_FIELD: Partial<Record<keyof InspectionFormValues, ColonyTakeoverField>> = {
|
||||
colonyStrength: "occupiedFrames",
|
||||
broodPattern: "broodStatus",
|
||||
feedStatus: "feedStatus",
|
||||
};
|
||||
|
||||
type SetValues = (update: (current: InspectionFormValues) => InspectionFormValues) => void;
|
||||
|
||||
/** "2026-10-01T00:00:00.000Z" -> "01.10.2026", whatever the phone's time zone. */
|
||||
@@ -186,6 +199,8 @@ function InspectionCaptureForm({
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = useCurrentUserId();
|
||||
const { hasPermission } = useAuth();
|
||||
const canUpdateColony = hasPermission("colonies:update");
|
||||
|
||||
const [values, setValues] = useState<InspectionFormValues>(() =>
|
||||
initialInspectionValues(initialColonyId),
|
||||
@@ -196,19 +211,49 @@ function InspectionCaptureForm({
|
||||
const draft = useFormDraft("inspection-new", values, dirty);
|
||||
const { inspection: last } = useLastInspection(values.colonyId);
|
||||
const weatherNotice = useWeatherAutofill(values, setValues);
|
||||
const { data: colonies } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const colonyCard = colonies?.find((colony) => colony.id === values.colonyId);
|
||||
const stand = useStandRound(roundApiaryId, values.colonyId);
|
||||
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
|
||||
|
||||
/**
|
||||
* Every change of the answers also keeps the Volkskarte selection in step.
|
||||
* `autoTick: false` for values copied from the last inspection: observations
|
||||
* are never silently handed to the colony card.
|
||||
*/
|
||||
function update(
|
||||
change: (current: InspectionFormValues) => InspectionFormValues,
|
||||
options?: { autoTick?: boolean; touched?: ColonyTakeoverField },
|
||||
) {
|
||||
setValues((current) => {
|
||||
const next = change(current);
|
||||
// A chosen feed level replaces the old text the hint talked about.
|
||||
const settled =
|
||||
next.feedStatus !== null && next.feedLegacyText !== null
|
||||
? { ...next, feedLegacyText: null }
|
||||
: next;
|
||||
return syncTakeoverSelection(current, settled, options);
|
||||
});
|
||||
}
|
||||
|
||||
function set<K extends keyof InspectionFormValues>(
|
||||
key: K,
|
||||
value: InspectionFormValues[K],
|
||||
) {
|
||||
const typedWeather = key === "weather" || key === "temperature";
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(typedWeather ? { weatherAuto: false } : {}),
|
||||
}));
|
||||
update(
|
||||
(current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(typedWeather ? { weatherAuto: false } : {}),
|
||||
// Choosing „Nicht geprüft“ on purpose also ends the old-text hint.
|
||||
...(key === "feedStatus" ? { feedLegacyText: null } : {}),
|
||||
}),
|
||||
{ touched: TOUCHED_TAKEOVER_FIELD[key] },
|
||||
);
|
||||
setDirty(true);
|
||||
if (key === "colonyId") setColonyError(null);
|
||||
if (key === "date") setDateError(null);
|
||||
@@ -291,6 +336,13 @@ function InspectionCaptureForm({
|
||||
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
|
||||
});
|
||||
toast.success("Durchsicht gespeichert");
|
||||
const takeover = outcome.result.colonyTakeover;
|
||||
if (takeover?.applied.length) {
|
||||
// The colony card changed: its page and lists must not show the old values.
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
}
|
||||
const notice = takeoverSkipNotice(takeover?.skipped ?? []);
|
||||
if (notice) toast.info(notice);
|
||||
void handOverPhotos(submittedPhotoSession.current, outcome.result.id);
|
||||
}
|
||||
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
|
||||
@@ -315,7 +367,7 @@ function InspectionCaptureForm({
|
||||
return;
|
||||
}
|
||||
submittedPhotoSession.current = values.photoSessionId;
|
||||
capture.submit(toInspectionInput(values));
|
||||
capture.submit(toCreateInspectionInput(values, canUpdateColony));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,12 +396,13 @@ function InspectionCaptureForm({
|
||||
// A draft from before photos existed has no session: the form keeps its own.
|
||||
const photoSessionId = restored.photoSessionId ?? freshSessionId;
|
||||
// A draft from before these fields existed lacks them.
|
||||
setValues({
|
||||
...initialInspectionValues(restored.colonyId),
|
||||
...restored,
|
||||
photoSessionId,
|
||||
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
|
||||
});
|
||||
setValues(
|
||||
currentDraftValues(restored, {
|
||||
photoSessionId,
|
||||
weatherAuto:
|
||||
restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
|
||||
}),
|
||||
);
|
||||
setDirty(true);
|
||||
if (photoSessionId !== freshSessionId) {
|
||||
void takePhotosAlong(freshSessionId, photoSessionId);
|
||||
@@ -368,7 +421,7 @@ function InspectionCaptureForm({
|
||||
}
|
||||
|
||||
function takeOver(field: TakeoverField, value: NonNullable<ReturnType<typeof previousValueOf>>) {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
update((current) => ({ ...current, [field]: value }), { autoTick: false });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
@@ -380,12 +433,12 @@ function InspectionCaptureForm({
|
||||
return;
|
||||
}
|
||||
const before = values;
|
||||
setValues(next);
|
||||
update(() => next, { autoTick: false });
|
||||
setDirty(true);
|
||||
toast.success(`${taken.length} Werte übernommen`, {
|
||||
action: {
|
||||
label: "Rückgängig",
|
||||
onClick: () => setValues((current) => undoTakeOver(current, before, next, taken)),
|
||||
onClick: () => update((current) => undoTakeOver(current, before, next, taken), { autoTick: false }),
|
||||
},
|
||||
duration: UNDO_TOAST_MS,
|
||||
});
|
||||
@@ -569,6 +622,7 @@ function InspectionCaptureForm({
|
||||
id={id}
|
||||
value={values.colonyStrength}
|
||||
onChange={(count) => set("colonyStrength", count)}
|
||||
max={MAX_OCCUPIED_FRAMES}
|
||||
unitLabel="besetzte Waben"
|
||||
decrementLabel="Eine besetzte Wabe weniger"
|
||||
incrementLabel="Eine besetzte Wabe mehr"
|
||||
@@ -638,6 +692,12 @@ function InspectionCaptureForm({
|
||||
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
||||
className="grid-cols-2 sm:grid-cols-4"
|
||||
/>
|
||||
{values.feedLegacyText !== null ? (
|
||||
<p role="status" className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
{feedLegacyHint(values.feedLegacyText)}
|
||||
</p>
|
||||
) : null}
|
||||
{hintFor("feedStatus")}
|
||||
</>
|
||||
)}
|
||||
@@ -650,7 +710,12 @@ function InspectionCaptureForm({
|
||||
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
|
||||
>
|
||||
{/* Present while empty (only visually hidden), or its first message is not announced. */}
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:sr-only">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="weather-notice"
|
||||
className="text-sm text-muted-foreground empty:sr-only"
|
||||
>
|
||||
{weatherNotice}
|
||||
</p>
|
||||
<Field label="Wetter">
|
||||
@@ -750,6 +815,22 @@ function InspectionCaptureForm({
|
||||
onChange={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
<ColonyTakeoverSection
|
||||
colony={
|
||||
colonyCard
|
||||
? {
|
||||
occupiedFrames: colonyCard.occupiedFrames ?? null,
|
||||
broodStatus: colonyCard.broodStatus ?? null,
|
||||
feedStatus: colonyCard.feedStatus ?? null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
values={values}
|
||||
selected={values.applyToColony}
|
||||
onChange={(applyToColony) => set("applyToColony", applyToColony)}
|
||||
canUpdateColony={canUpdateColony}
|
||||
/>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
capture.submitError ? (
|
||||
|
||||
Reference in new issue
Block a user