feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
Backend Image / build-and-push (push) Successful in 6m52s
Frontend Image / build-and-push (push) Failing after 12m28s
Manifests / validate (push) Successful in 22s

- Treatment form and bulk entry: catalogue medicine or free text, lot with
  oldest preselected on create, lot fields from the lot, dose with at most
  3 decimals, catalogue prefill with explicit take-over of the withdrawal
  period on a medicine switch, ?editTreatment deep link.
- Authority notices: list with year filter, create with draft dedupe link,
  detail steps with PDF download and mark-sent, settings with MM-TT check,
  digit-rule editor and "Annahme – bitte prüfen" notes; COMPLIANCE label
  in notifications and Telegram categories.
- Restricted zone: page with active/planned/ended, create and end dialogs
  (endsOn inclusive), red band at the apiary, guards on move, merge, edit
  and new colony, override only with compliance:manage, harvest warning.
- ARCHITECTURE.md §12.11, Annahmen 91–94; TASKS.md T6 done with open items.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 17:33:55 +02:00
1 parent 13dda0e139
commit 8188e4cab3
90 files changed
+8069 -265

No files matched your search

@@ -2,12 +2,17 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import ApiaryDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(),
exportTreatmentLogPdf: vi.fn(),
@@ -105,3 +110,33 @@ describe("ApiaryDetailPage", () => {
expect(screen.queryByText(/Das Bestandsbuch braucht das Recht/)).not.toBeInTheDocument();
});
});
describe("ApiaryDetailPage – Sperrbezirk", () => {
const restriction = {
id: "r-1",
apiaryId: "a-1",
apiaryName: "Obstwiese",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
};
it("the apiary page shows a red band with a link while a restriction is active", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
renderPage(["compliance:read"]);
const text = await screen.findByText(/Stand Obstwiese liegt im Sperrbezirk/);
expect(text.closest("[data-restriction-band]")).toHaveClass("text-destructive");
expect(screen.getByRole("link", { name: "Sperrbezirke ansehen" })).toHaveAttribute(
"href",
"/compliance/restrictions",
);
});
it("shows no band when only another apiary is restricted", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([{ ...restriction, apiaryId: "a-9" }]);
renderPage([]);
await screen.findByText("Saison von Obstwiese");
expect(screen.queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument();
});
});
@@ -9,6 +9,9 @@ import { useAuth } from "@/lib/auth/auth-context";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { RestrictionBand } from "@/components/compliance/restriction-notice";
import { restrictionsAmong } from "@/lib/compliance/restrictions-api";
import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
import { ApiarySeason } from "@/components/phenology/apiary-season";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
@@ -26,6 +29,7 @@ export default function ApiaryDetailPage() {
queryFn: () => fetchApiary(params.id),
});
const apiary = apiaryQuery.data;
const { restrictions } = useActiveRestrictions();
const exportReportMutation = useMutation({
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
@@ -84,6 +88,10 @@ export default function ApiaryDetailPage() {
]}
{...BACK}
/>
<RestrictionBand
restrictions={restrictionsAmong([apiary.id], restrictions)}
canOpenList={hasPermission("compliance:read")}
/>
{!canReadMedicines ? (
<p className="text-sm text-muted-foreground">
Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, 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, splitColony } from "@/lib/colonies/api";
import { fetchColonies, mergeColony, moveColony, splitColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates";
import type { Colony, ColonyStatus } from "@/lib/colonies/types";
@@ -22,6 +24,15 @@ vi.mock("@/lib/queens/api", () => ({
assignQueenColony: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony {
return {
@@ -247,3 +258,258 @@ describe("ColonyActions – split", () => {
expect(splitColony).not.toHaveBeenCalled();
});
});
const FAULBRUT = "Amerikanische Faulbrut";
const REASON = "Amtstierarzt hat das Umsetzen mündlich erlaubt.";
const HINT =
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.";
function restriction(apiaryId: string, apiaryName: string) {
return { id: `r-${apiaryId}`, apiaryId, apiaryName, disease: FAULBRUT, startsOn: "2026-10-01" };
}
describe("ColonyActions – Sperrbezirk beim Umsetzen", () => {
beforeEach(() => {
permissions = [];
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "apiary-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
vi.mocked(moveColony).mockReset();
vi.mocked(moveColony).mockResolvedValue(target);
});
async function openMove() {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
return screen.findByRole("dialog");
}
async function chooseTarget(dialog: HTMLElement, name = "Hausgarten") {
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name }));
}
const submit = (dialog: HTMLElement) => within(dialog).getByRole("button", { name: "Umsetzen" });
it("always shows the exact BienSeuchV § 5 hint in the move dialog", async () => {
const dialog = await openMove();
expect(within(dialog).getByText(HINT)).toBeInTheDocument();
});
it("moves without any override field when nothing is restricted", async () => {
permissions = ["compliance:manage"];
const dialog = await openMove();
await chooseTarget(dialog);
expect(within(dialog).queryByRole("button", { name: /Ausnahme mit Begründung/ })).not.toBeInTheDocument();
await userEvent.click(submit(dialog));
expect(moveColony).toHaveBeenCalledWith("colony-1", { apiaryId: "apiary-2" });
});
it("the move dialog shows the red notice and disables Umsetzen while the target apiary is restricted", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
const dialog = await openMove();
await chooseTarget(dialog);
const notice = await within(dialog).findByText(
`Stand Hausgarten liegt im Sperrbezirk (${FAULBRUT}, seit 01.10.2026). Umsetzen ist gesperrt.`,
);
expect(notice.closest("[data-restriction-notice]")).toHaveClass("text-destructive");
expect(submit(dialog)).toHaveAttribute("aria-disabled", "true");
expect(submit(dialog).getAttribute("aria-describedby")).toBe(
notice.closest("[data-restriction-notice]")?.id,
);
});
it("disables Umsetzen from the start while the source apiary is restricted", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-1", "Obstwiese")]);
const dialog = await openMove();
expect(await within(dialog).findByText(/Stand Obstwiese liegt im Sperrbezirk/)).toBeInTheDocument();
await chooseTarget(dialog);
expect(submit(dialog)).toHaveAttribute("aria-disabled", "true");
});
it("hides the override field without compliance:manage", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
const dialog = await openMove();
await chooseTarget(dialog);
await within(dialog).findByText(/liegt im Sperrbezirk/);
expect(within(dialog).queryByRole("button", { name: /Ausnahme mit Begründung/ })).not.toBeInTheDocument();
expect(submit(dialog)).toHaveAttribute("aria-disabled", "true");
});
it("enables Umsetzen after a reason of 10+ characters and a confirmation, and sends the reason", async () => {
permissions = ["compliance:manage"];
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
const dialog = await openMove();
await chooseTarget(dialog);
await userEvent.click(await within(dialog).findByRole("button", { name: /Ausnahme mit Begründung/ }));
await userEvent.type(within(dialog).getByLabelText(/Begründung der Ausnahme/), REASON);
expect(submit(dialog)).toHaveAttribute("aria-disabled", "true");
await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme nutzen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" }));
await waitFor(() => expect(submit(dialog)).not.toHaveAttribute("aria-disabled"));
await userEvent.click(submit(dialog));
expect(moveColony).toHaveBeenCalledWith("colony-1", {
apiaryId: "apiary-2",
restrictionOverrideReason: REASON,
});
});
it("shows the server 409 RESTRICTED_ZONE message and puts the focus on it when a race slips past", async () => {
vi.mocked(moveColony).mockRejectedValue(
new ApiError(409, "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.", {
code: "RESTRICTED_ZONE",
message: "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.",
restrictionIds: ["r-9"],
restrictions: [],
}),
);
const dialog = await openMove();
await chooseTarget(dialog);
await userEvent.click(submit(dialog));
const alert = await within(dialog).findByRole("alert");
expect(alert).toHaveTextContent("Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.");
await waitFor(() => expect(alert).toHaveFocus());
});
it("shows the 409 text of an apiary that changed meanwhile", async () => {
vi.mocked(moveColony).mockRejectedValue(
new ApiError(409, "Der Stand wurde gerade geändert. Bitte neu laden."),
);
const dialog = await openMove();
await chooseTarget(dialog);
await userEvent.click(submit(dialog));
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Der Stand wurde gerade geändert. Bitte neu laden.",
);
});
});
describe("ColonyActions – Sperrbezirk beim Vereinigen", () => {
const faraway = { ...colony("colony-2", "V-02"), apiaryId: "apiary-2" };
beforeEach(() => {
permissions = ["compliance:manage"];
vi.mocked(fetchColonies).mockResolvedValue([target, faraway]);
vi.mocked(mergeColony).mockReset();
vi.mocked(mergeColony).mockResolvedValue(target);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
});
async function pickMerged() {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Vereinigen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "V-02" }));
return dialog;
}
it("shows the notice and keeps Vereinigen off until the override is used", async () => {
const dialog = await pickMerged();
expect(await within(dialog).findByText(/Stand Hausgarten liegt im Sperrbezirk/)).toBeInTheDocument();
const trigger = within(dialog).getByRole("button", { name: "Vereinigen" });
expect(trigger).toHaveAttribute("aria-disabled", "true");
expect(trigger.getAttribute("aria-describedby")).toBeTruthy();
});
it("sends restrictionOverrideReason with the merge only when the override is used", async () => {
const dialog = await pickMerged();
await userEvent.click(await within(dialog).findByRole("button", { name: /Ausnahme mit Begründung/ }));
await userEvent.type(within(dialog).getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme nutzen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" }));
await waitFor(() => expect(within(dialog).getByRole("button", { name: "Vereinigen" })).not.toHaveAttribute("aria-disabled"));
await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" }));
await userEvent.click(await screen.findByRole("button", { name: "V-02 in V-01 aufgehen lassen" }));
expect(mergeColony).toHaveBeenCalledWith("colony-1", {
mergedColonyIds: ["colony-2"],
restrictionOverrideReason: REASON,
});
});
it("does not block a merge inside one restricted apiary", async () => {
vi.mocked(fetchColonies).mockResolvedValue([target, colony("colony-2", "V-02")]);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-1", "Obstwiese")]);
const dialog = await pickMerged();
await waitFor(() => expect(within(dialog).getByRole("button", { name: "Vereinigen" })).not.toHaveAttribute("aria-disabled"));
expect(within(dialog).queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument();
});
});
describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => {
beforeEach(() => {
permissions = [];
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "apiary-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
vi.mocked(moveColony).mockReset();
});
async function failMove(error: ApiError) {
vi.mocked(moveColony).mockRejectedValue(error);
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Umsetzen" }));
return dialog;
}
it("falls back to a fixed sentence when RESTRICTED_ZONE comes without a message", async () => {
const dialog = await failMove(
new ApiError(409, "", { code: "RESTRICTED_ZONE", restrictionIds: ["r-1"], restrictions: [] }),
);
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Stand liegt im Sperrbezirk.");
});
it("offers Neu laden after \"Bitte neu laden\" and reloads the colony", async () => {
const dialog = await failMove(new ApiError(409, "Der Stand wurde gerade geändert. Bitte neu laden."));
vi.mocked(fetchColonies).mockClear();
await userEvent.click(await within(dialog).findByRole("button", { name: "Neu laden" }));
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
});
it("does not offer Neu laden for a restricted zone", async () => {
const dialog = await failMove(
new ApiError(409, "Der Bienenstand liegt im Sperrbezirk.", { code: "RESTRICTED_ZONE" }),
);
await within(dialog).findByRole("alert");
expect(within(dialog).queryByRole("button", { name: "Neu laden" })).not.toBeInTheDocument();
});
it("keeps the blocked Umsetzen button reachable by keyboard and does not move on click", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
await within(dialog).findByText(/liegt im Sperrbezirk/);
const button = within(dialog).getByRole("button", { name: "Umsetzen" });
expect(button).not.toBeDisabled();
button.focus();
expect(button).toHaveFocus();
await userEvent.click(button);
expect(moveColony).not.toHaveBeenCalled();
});
});
@@ -36,6 +36,9 @@ import {
} from "@/components/ui/dialog";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard";
import { BLOCKED_TEXT, MoveHint } from "@/components/compliance/restriction-notice";
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
function invalidateColony(
queryClient: ReturnType<typeof useQueryClient>,
@@ -46,21 +49,49 @@ function invalidateColony(
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] });
}
/** "Neu laden" after a 409: the colony may have been moved meanwhile. */
function reloadColony(
queryClient: ReturnType<typeof useQueryClient>,
colonyId: string,
clearError: () => void,
): void {
invalidateColony(queryClient, colonyId);
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
clearError();
}
/** A failed action explains itself right above the button that caused it. */
function ActionFooter({
error,
focusError = false,
onReload,
children,
}: {
error: string | null;
/** A 409: the focus jumps to the message. */
focusError?: boolean;
/** After a 409 "Bitte neu laden": loads the colony again and clears the message. */
onReload?: () => void;
children: ReactNode;
}) {
const errorRef = useFocusWhen(error !== null && focusError);
return (
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
<p
ref={errorRef}
role="alert"
tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
>
{error}
</p>
) : null}
{error && onReload ? (
<Button type="button" variant="outline" size="touch" onClick={onReload}>
Neu laden
</Button>
) : null}
{children}
</DialogFooter>
);
@@ -71,14 +102,18 @@ function MoveDialog({ colony }: { colony: Colony }) {
const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState("");
const [error, setError] = useState<string | null>(null);
const [focusError, setFocusError] = useState(false);
const [canReload, setCanReload] = useState(false);
const { data: apiaries } = useQuery({
queryKey: ["apiaries", { status: "ACTIVE" }],
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
});
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId);
// Source and target: moving out of or into a restricted zone is blocked.
const guard = useRestrictionGuard(apiaryId ? [colony.apiaryId, apiaryId] : [colony.apiaryId]);
const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId }),
mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }),
onSuccess: () => {
invalidateColony(queryClient, colony.id);
const target = targets.find((apiary) => apiary.id === apiaryId);
@@ -87,12 +122,23 @@ function MoveDialog({ colony }: { colony: Colony }) {
);
setOpen(false);
},
onError: (mutationError) =>
setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")),
onError: (mutationError) => {
const explained = guard.explain(mutationError, "Volk konnte nicht umgesetzt werden.");
setError(explained.text);
setFocusError(explained.focus);
setCanReload(explained.reload);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) guard.refresh();
else guard.reset();
}}
>
<DialogTrigger
render={
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
@@ -108,10 +154,12 @@ function MoveDialog({ colony }: { colony: Colony }) {
festgehalten.
</DialogDescription>
</DialogHeader>
<MoveHint />
<form
className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => {
event.preventDefault();
if (!guard.canProceed) return;
setError(null);
mutation.mutate();
}}
@@ -128,8 +176,19 @@ function MoveDialog({ colony }: { colony: Colony }) {
/>
)}
</Field>
<ActionFooter error={error}>
<Button type="submit" size="touch" disabled={!apiaryId || mutation.isPending}>
<RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.move} />
<ActionFooter
error={error}
focusError={focusError}
onReload={canReload ? () => reloadColony(queryClient, colony.id, () => setError(null)) : undefined}
>
<Button
type="submit"
size="touch"
disabled={!apiaryId || mutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
>
{mutation.isPending ? "Wird umgesetzt…" : "Umsetzen"}
</Button>
</ActionFooter>
@@ -374,14 +433,21 @@ function MergeDialog({ colony }: { colony: Colony }) {
const [open, setOpen] = useState(false);
const [mergedColonyId, setMergedColonyId] = useState("");
const [error, setError] = useState<string | null>(null);
const [focusError, setFocusError] = useState(false);
const [canReload, setCanReload] = useState(false);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const merged = (colonies ?? []).find(
(candidate) =>
candidate.id === mergedColonyId && candidate.id !== colony.id && isLivingColony(candidate),
);
// Merging inside one apiary leaves no apiary; only a merge across apiaries is blocked.
const guard = useRestrictionGuard(
merged && merged.apiaryId !== colony.apiaryId ? [colony.apiaryId, merged.apiaryId] : [],
);
const mutation = useMutation({
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }),
mutationFn: () =>
mergeColony(colony.id, { mergedColonyIds: [mergedColonyId], ...guard.overrideFields() }),
onSuccess: () => {
invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -390,12 +456,23 @@ function MergeDialog({ colony }: { colony: Colony }) {
);
setOpen(false);
},
onError: (mutationError) =>
setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")),
onError: (mutationError) => {
const explained = guard.explain(mutationError, "Völker konnten nicht vereinigt werden.");
setError(explained.text);
setFocusError(explained.focus);
setCanReload(explained.reload);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) guard.refresh();
else guard.reset();
}}
>
<DialogTrigger
render={
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
@@ -422,12 +499,19 @@ function MergeDialog({ colony }: { colony: Colony }) {
setError(null);
}}
/>
<ActionFooter error={error}>
<RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.merge} />
<ActionFooter
error={error}
focusError={focusError}
onReload={canReload ? () => reloadColony(queryClient, colony.id, () => setError(null)) : undefined}
>
<ConfirmDialog
triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"}
triggerVariant="default"
triggerSize="touch"
triggerDisabled={!merged || mutation.isPending}
triggerAriaDisabled={!guard.canProceed}
triggerDescribedBy={guard.canProceed ? undefined : guard.noticeId}
title="Völker wirklich vereinigen?"
description={`Volk ${merged?.code ?? ""} geht in Volk ${colony.code} auf und wird beendet. Das lässt sich nicht rückgängig machen.`}
confirmLabel={`${merged?.code ?? "Volk"} in ${colony.code} aufgehen lassen`}
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() }));
@@ -12,6 +13,14 @@ vi.mock("@/lib/apiaries/api", () => ({
fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const colony: Colony = {
id: "colony-1",
@@ -245,3 +254,76 @@ describe("ColonyEditCard – legacy strength after clearing", () => {
expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument();
});
});
const REASON = "Amtstierarzt hat die Standkorrektur erlaubt.";
describe("ColonyEditCard – Sperrbezirk bei der Standkorrektur", () => {
beforeEach(() => {
permissions = [];
vi.mocked(updateColony).mockReset();
vi.mocked(updateColony).mockResolvedValue(colony);
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Obstwiese" },
{ id: "apiary-2", name: "Hausgarten" },
] as never);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-2",
apiaryId: "apiary-2",
apiaryName: "Hausgarten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
});
async function pickHausgarten() {
renderCard();
await choose(/Bienenstand/, "Hausgarten");
}
it("shows the notice and disables Speichern when the corrected apiary is restricted", async () => {
await pickHausgarten();
expect(
await screen.findByText(
"Stand Hausgarten liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Standwechsel ist gesperrt.",
),
).toBeInTheDocument();
const save = screen.getByRole("button", { name: "Speichern" });
expect(save).toHaveAttribute("aria-disabled", "true");
expect(save.getAttribute("aria-describedby")).toBeTruthy();
});
it("saves other fields without notice while the apiary stays where it is", async () => {
renderCard({ ...colony, apiaryId: "apiary-2" });
await userEvent.type(screen.getByLabelText("Beutentyp"), "x");
await waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).not.toHaveAttribute("aria-disabled"));
expect(screen.queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument();
});
it("sends the override reason with the correction after the confirmation", async () => {
permissions = ["compliance:manage"];
await pickHausgarten();
await userEvent.click(await screen.findByRole("button", { name: /Ausnahme mit Begründung/ }));
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).not.toHaveAttribute("aria-disabled"));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({
apiaryId: "apiary-2",
restrictionOverrideReason: REASON,
});
});
it("does not send a reason when nothing is restricted", async () => {
renderCard();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).not.toHaveProperty("restrictionOverrideReason");
});
});
@@ -18,7 +18,6 @@ import {
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";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -28,6 +27,9 @@ 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 { RestrictionGuardPanel } from "@/components/compliance/restriction-guard";
import { BLOCKED_TEXT } from "@/components/compliance/restriction-notice";
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
import {
Card,
CardContent,
@@ -88,6 +90,13 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
colony.healthStatus,
);
const [notes, setNotes] = useState(colony.notes ?? "");
const [conflictText, setConflictText] = useState<string | null>(null);
const [canReload, setCanReload] = useState(false);
const conflictRef = useFocusWhen(conflictText !== null);
// Only a correction of the apiary is blocked; other fields stay editable.
const guard = useRestrictionGuard(
apiaryId !== colony.apiaryId ? [colony.apiaryId, apiaryId] : [],
);
const apiariesQuery = useQuery({
queryKey: ["apiaries"],
@@ -119,6 +128,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
feedStatus,
healthStatus,
notes: emptyToNull(notes),
...guard.overrideFields(),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -126,12 +136,19 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
toast.success("Volk gespeichert");
},
onError: (error) =>
toast.error(errorMessage(error, "Volk konnte nicht gespeichert werden.")),
onError: (error) => {
const explained = guard.explain(error, "Volk konnte nicht gespeichert werden.");
// A 409 (restricted zone, apiary changed meanwhile) stays on the page, focused.
setCanReload(explained.reload);
if (explained.focus) setConflictText(explained.text);
else toast.error(explained.text);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!guard.canProceed) return;
setConflictText(null);
updateMutation.mutate();
}
@@ -260,7 +277,38 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" size="touch" disabled={updateMutation.isPending}>
<RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.correct} />
{conflictText ? (
<p
ref={conflictRef}
role="alert"
tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
>
{conflictText}
</p>
) : null}
{conflictText && canReload ? (
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] });
setConflictText(null);
}}
>
Neu laden
</Button>
) : null}
<Button
type="submit"
size="touch"
disabled={updateMutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
>
{updateMutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyTreatmentsCard } from "./colony-treatments";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import type { Treatment } from "@/lib/treatments/types";
vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() }));
vi.mock("./treatment-edit-dialog", () => ({
TreatmentEditDialog: ({ treatment, defaultOpen }: { treatment: Treatment; defaultOpen?: boolean }) => (
<p>
Dialog {treatment.id} {defaultOpen ? "offen" : "zu"}
</p>
),
}));
function treatment(id: string): Treatment {
return {
id,
colonyId: "colony-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
startDate: null,
endDate: null,
product: `Mittel ${id}`,
activeIngredient: null,
amount: null,
method: null,
reason: null,
batchNumber: null,
notes: null,
withdrawalPeriodDays: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
medicineId: null,
medicineLotId: null,
doseQuantity: null,
createdAt: "2026-08-01T00:00:00.000Z",
};
}
function renderCard(editTreatmentId?: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyTreatmentsCard colonyId="colony-1" editTreatmentId={editTreatmentId} />
</QueryClientProvider>,
);
}
describe("ColonyTreatmentsCard", () => {
beforeEach(() => {
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([treatment("t-1"), treatment("t-2")]);
});
it("opens the edit dialog only for the treatment named by ?editTreatment=<id>", async () => {
renderCard("t-2");
expect(await screen.findByText("Dialog t-2 offen")).toBeInTheDocument();
expect(screen.getByText("Dialog t-1 zu")).toBeInTheDocument();
});
it("opens no dialog without the parameter or for an unknown id", async () => {
renderCard("t-unknown");
expect(await screen.findByText("Dialog t-1 zu")).toBeInTheDocument();
expect(screen.queryByText(/offen/)).not.toBeInTheDocument();
});
it("says so when ?editTreatment names a treatment that is not in the list", async () => {
renderCard("t-unknown");
expect(await screen.findByText("Behandlung nicht gefunden.")).toBeInTheDocument();
});
it("says nothing while the list is loading or the treatment exists", async () => {
renderCard("t-2");
await screen.findByText("Dialog t-2 offen");
expect(screen.queryByText("Behandlung nicht gefunden.")).not.toBeInTheDocument();
});
});
@@ -10,7 +10,14 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TreatmentEditDialog } from "./treatment-edit-dialog";
export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
export function ColonyTreatmentsCard({
colonyId,
editTreatmentId,
}: {
colonyId: string;
/** The treatment whose edit dialog opens with the page (`?editTreatment=<id>`). */
editTreatmentId?: string;
}) {
const treatmentsQuery = useQuery({
queryKey: ["colonies", colonyId, "treatments"],
queryFn: () => fetchColonyTreatments(colonyId),
@@ -30,6 +37,11 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div role="status" aria-live="polite" className="empty:hidden">
{editTreatmentId && treatmentsQuery.data && !treatmentsQuery.data.some((t) => t.id === editTreatmentId) ? (
<p className="rounded-lg bg-warning/20 px-3 py-2">Behandlung nicht gefunden.</p>
) : null}
</div>
<QueryState
query={treatmentsQuery}
what="Behandlungen"
@@ -48,7 +60,10 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
<p className="font-medium">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p>
<TreatmentEditDialog treatment={treatment} />
<TreatmentEditDialog
treatment={treatment}
defaultOpen={treatment.id === editTreatmentId}
/>
</div>
<p className="text-muted-foreground">
{treatment.amount ?? "—"}
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -6,8 +6,10 @@ import ColonyDetailPage from "./page";
import { fetchColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
const search = vi.hoisted(() => ({ query: "" }));
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "colony-1" }),
useSearchParams: () => new URLSearchParams(search.query),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
@@ -21,7 +23,11 @@ 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-treatments", () => ({
ColonyTreatmentsCard: ({ editTreatmentId }: { editTreatmentId?: string }) => (
<p>Behandlungen, Dialog für {editTreatmentId ?? "keine"}</p>
),
}));
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/autopilot/next-steps-card", () => ({
@@ -121,3 +127,35 @@ describe("ColonyDetailPage – Als Nächstes", () => {
).toBeTruthy();
});
});
describe("ColonyDetailPage – ?editTreatment", () => {
const colony = {
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony;
beforeEach(() => {
vi.mocked(fetchColony).mockReset().mockResolvedValue(colony);
});
afterEach(() => {
search.query = "";
});
it("the colony page opens the edit dialog for ?editTreatment=<id>", async () => {
search.query = "editTreatment=t-7";
renderPage();
expect(await screen.findByText("Behandlungen, Dialog für t-7")).toBeInTheDocument();
});
it("opens no dialog without the parameter", async () => {
renderPage();
expect(await screen.findByText("Behandlungen, Dialog für keine")).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react";
import { toast } from "sonner";
@@ -27,6 +27,8 @@ const BACK = { backHref: "/colonies", backLabel: "Alle Völker" };
export default function ColonyDetailPage() {
const params = useParams<{ id: string }>();
// A link from the compliance check opens the edit dialog of one treatment.
const editTreatmentId = useSearchParams().get("editTreatment") ?? undefined;
const colonyQuery = useQuery({
queryKey: ["colonies", params.id],
queryFn: () => fetchColony(params.id),
@@ -99,7 +101,7 @@ export default function ColonyDetailPage() {
<ColonyInspectionsCard colonyId={colony.id} />
<ColonyVarroaCard colonyId={colony.id} />
<ColonySwarmControlCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} editTreatmentId={editTreatmentId} />
<ColonyFeedingsCard colonyId={colony.id} />
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
</div>
@@ -0,0 +1,281 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TreatmentEditDialog } from "./treatment-edit-dialog";
import { updateTreatment } from "@/lib/treatments/api";
import type { Treatment } from "@/lib/treatments/types";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import { ApiError } from "@/lib/auth/api-client";
const granted = vi.hoisted(() => new Set<string>());
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useSessionMode: () => "online",
useAuth: () => ({ hasPermission: (permission: string) => granted.has(permission) }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ updateTreatment: vi.fn() }));
vi.mock("@/lib/medicines/api", () => ({ fetchMedicines: vi.fn(), fetchSelectableLots: vi.fn() }));
const OXUVAR = medicine({ id: "m-oxuvar", name: "Oxuvar", unit: "ML", withdrawalPeriodDays: 0 });
const APIVAR = medicine({ id: "m-apivar", name: "Apivar", unit: "PIECE", withdrawalPeriodDays: 21 });
const LOT = lot({
id: "l-old",
medicineId: "m-oxuvar",
unit: "ML",
batchNumber: "4711",
remaining: 0,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
});
const FREE_TEXT: Treatment = {
id: "t-1",
colonyId: "colony-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
startDate: null,
endDate: null,
product: "Oxuvar",
activeIngredient: "Oxalsäure",
amount: "50 ml",
method: null,
reason: null,
batchNumber: "Ch-1",
notes: "alt",
withdrawalPeriodDays: 0,
supplier: "Imkereibedarf Muster",
purchaseProof: null,
prescribingVet: null,
medicineId: null,
medicineLotId: null,
doseQuantity: null,
createdAt: "2026-08-01T00:00:00.000Z",
};
const FROM_PACK: Treatment = {
...FREE_TEXT,
id: "t-2",
batchNumber: null,
supplier: null,
amount: null,
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 30,
};
async function openDialog(treatment: Treatment) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<TreatmentEditDialog treatment={treatment} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
return screen.findByRole("dialog");
}
describe("TreatmentEditDialog", () => {
beforeEach(() => {
granted.clear();
granted.add("veterinary-medicines:read");
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(fetchMedicines).mockReset().mockResolvedValue([OXUVAR]);
vi.mocked(fetchSelectableLots).mockReset().mockResolvedValue([LOT]);
vi.mocked(updateTreatment).mockReset().mockResolvedValue(FREE_TEXT);
});
it("opens straight away with defaultOpen", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<TreatmentEditDialog treatment={FREE_TEXT} defaultOpen />
</QueryClientProvider>,
);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Behandlung bearbeiten")).toBeInTheDocument();
});
it("shows the pack of the treatment, asks for it with includeLotId and hides the lot-sourced fields", async () => {
const dialog = await openDialog(FROM_PACK);
await waitFor(() => expect(fetchSelectableLots).toHaveBeenCalledWith("m-oxuvar", "l-old"));
expect(await screen.findByText(/^aus Packung: Ch\.-B\. 4711/)).toBeInTheDocument();
expect(dialog).toHaveTextContent("Rest 0 ml");
expect(screen.getByLabelText(/Dosis/)).toHaveValue("30");
expect(screen.queryByLabelText(/Lieferant/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Chargennummer/)).not.toBeInTheDocument();
});
it("a notes-only edit sends none of the medicine fields", async () => {
await openDialog(FROM_PACK);
await screen.findByText(/^aus Packung/);
await userEvent.type(screen.getByLabelText(/^Notizen/), " neu");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1));
const [, patch] = vi.mocked(updateTreatment).mock.calls[0];
expect(patch).toMatchObject({ notes: "alt neu" });
for (const omitted of [
"product",
"medicineId",
"medicineLotId",
"doseQuantity",
"supplier",
"purchaseProof",
"batchNumber",
"prescribingVet",
]) {
expect(patch).not.toHaveProperty(omitted);
}
});
it("a changed dose sends medicine, pack and dose and clears the lot-sourced texts with null", async () => {
await openDialog(FROM_PACK);
await screen.findByText(/^aus Packung/);
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "2,5");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1));
const [id, patch] = vi.mocked(updateTreatment).mock.calls[0];
expect(id).toBe("t-2");
expect(patch).toMatchObject({
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 2.5,
supplier: null,
purchaseProof: null,
batchNumber: null,
prescribingVet: null,
});
expect(patch).not.toHaveProperty("product");
});
it("choosing „Ohne Packung“ sends medicineLotId null and keeps the catalogue medicine", async () => {
await openDialog(FROM_PACK);
await screen.findByText(/^aus Packung/);
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1));
expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({
medicineId: "m-oxuvar",
medicineLotId: null,
doseQuantity: 30,
});
});
it("switching a catalogue treatment to „Anderes Mittel“ clears medicine, pack and dose", async () => {
await openDialog(FROM_PACK);
await screen.findByText(/^aus Packung/);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Anderes Mittel" }));
await userEvent.type(screen.getByLabelText(/^Mittel/), "Selbstgemischt");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1));
expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({
product: "Selbstgemischt",
medicineId: null,
medicineLotId: null,
doseQuantity: null,
});
});
it("a free-text treatment edits its texts as before and sends them with the medicine group", async () => {
await openDialog(FREE_TEXT);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1));
expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({
product: "Oxuvar",
supplier: null,
batchNumber: "Ch-1",
});
});
it("shows the server's reason in German at the form", async () => {
vi.mocked(updateTreatment).mockRejectedValue(
new ApiError(400, "Diese Angabe kommt aus der Packung", {
message: "Diese Angabe kommt aus der Packung",
}),
);
await openDialog(FREE_TEXT);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Diese Angabe kommt aus der Packung");
});
describe("switching the medicine", () => {
beforeEach(() => {
vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR]);
});
async function switchToApivar() {
await openDialog({ ...FROM_PACK, withdrawalPeriodDays: 7 });
await screen.findByText(/^aus Packung/);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
}
it("keeps the old withdrawal period, warns, and takes the catalogue value only on request", async () => {
await switchToApivar();
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(7);
expect(
await screen.findByText("Wartezeit stammt vom vorherigen Mittel – bitte prüfen."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "aus Katalog übernehmen" }));
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(21);
expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument();
});
it("does not warn when the new medicine has the same withdrawal period", async () => {
await openDialog({ ...FROM_PACK, withdrawalPeriodDays: 21 });
await screen.findByText(/^aus Packung/);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument();
});
it("an edit of the field removes the warning", async () => {
await switchToApivar();
await screen.findByText(/vom vorherigen Mittel/);
await userEvent.type(screen.getByLabelText(/Wartezeit/), "0");
expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument();
});
it("fills an empty withdrawal period with the note aus Katalog and never preselects a pack", async () => {
vi.mocked(fetchSelectableLots).mockResolvedValue([LOT]);
await openDialog({ ...FROM_PACK, withdrawalPeriodDays: null });
await screen.findByText(/^aus Packung/);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(21);
expect(screen.getAllByText("aus Katalog").length).toBeGreaterThan(0);
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
});
});
});
@@ -2,11 +2,25 @@
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { updateTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { Treatment } from "@/lib/treatments/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { catalogueValues, useCataloguePrefill } from "@/lib/treatments/use-catalogue-prefill";
import { doseError, doseText } from "@/lib/treatments/dose";
import { medicinePatch } from "@/lib/treatments/edit-patch";
import { lotSourceLine } from "@/lib/treatments/lot-text";
import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice";
import type { MedicineView } from "@/lib/medicines/types";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
CatalogueNote,
MedicineLotFields,
describedByCatalogue,
} from "@/components/treatments/medicine-lot-fields";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ColonySelectField } from "@/components/colony-select-field";
@@ -27,19 +41,41 @@ import {
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
interface CatalogueState {
activeIngredient: string;
withdrawalPeriodDays: string;
method: string;
doseQuantity: string;
}
interface FieldErrors {
date?: string;
product?: string;
doseQuantity?: string;
}
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const SAVE_FAILED = "Behandlung konnte nicht gespeichert werden.";
const STALE_WITHDRAWAL = "Wartezeit stammt vom vorherigen Mittel – bitte prüfen.";
export function TreatmentEditDialog({
treatment,
defaultOpen = false,
}: {
treatment: Treatment;
/** Opened by a link (`?editTreatment=<id>`). */
defaultOpen?: boolean;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { hasPermission } = useAuth();
const [open, setOpen] = useState(defaultOpen);
const [colonyId, setColonyId] = useState(treatment.colonyId);
const [date, setDate] = useState(treatment.date.slice(0, 10));
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
const [product, setProduct] = useState(treatment.product);
const [medicineId, setMedicineId] = useState(treatment.medicineId ?? "");
const [medicineLotId, setMedicineLotId] = useState(treatment.medicineLotId ?? "");
const [doseQuantity, setDoseQuantity] = useState(doseText(treatment.doseQuantity));
const [activeIngredient, setActiveIngredient] = useState(treatment.activeIngredient ?? "");
const [amount, setAmount] = useState(treatment.amount ?? "");
const [method, setMethod] = useState(treatment.method ?? "");
@@ -55,6 +91,63 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const choice = useMedicineChoice({
// Nothing is asked while the dialog is closed (every row has one).
canRead: open && hasPermission("veterinary-medicines:read"),
medicineId,
medicineLotId,
includeLotId: treatment.medicineLotId ?? undefined,
keepMedicineId: treatment.medicineId ?? undefined,
startDecided: true,
onPreselect: setMedicineLotId,
});
const hasLot = medicineLotId !== "";
// The catalogue fields as one object, so the same prefill hook as in the create form works here.
const catalogue = useCataloguePrefill<CatalogueState>(
(update) => {
const current = { activeIngredient, withdrawalPeriodDays, method, doseQuantity };
const next = typeof update === "function" ? update(current) : update;
setActiveIngredient(next.activeIngredient);
setWithdrawalPeriodDays(next.withdrawalPeriodDays);
setMethod(next.method);
setDoseQuantity(next.doseQuantity);
},
() => undefined,
);
// Set when a switch left a withdrawal period of the previous medicine in place
// (the value is the new catalogue value, or null when the catalogue has none).
const [staleWithdrawal, setStaleWithdrawal] = useState<string | null | undefined>(undefined);
const [catalogueReady, setCatalogueReady] = useState(false);
if (open && !catalogueReady) {
// Whatever the treatment already holds belongs to the user: only empty fields are filled.
setCatalogueReady(true);
catalogue.resetFrom({ activeIngredient, withdrawalPeriodDays, method, doseQuantity });
}
/**
* A catalogue medicine (null: free text) fills the fields that are empty, with the note
* "aus Katalog". A withdrawal period that is already there is never replaced silently: if
* it differs from the new medicine it is flagged and can be taken over explicitly.
*/
function chooseMedicine(medicine: MedicineView | null) {
const hadMedicine = medicineId !== "";
const oldWithdrawal = catalogue.filled.withdrawalPeriodDays ? "" : withdrawalPeriodDays.trim();
const known = medicine ? catalogueValues(medicine).withdrawalPeriodDays : undefined;
setMedicineId(medicine?.id ?? "");
setMedicineLotId("");
setProduct(medicine ? medicine.name : medicineId ? "" : product);
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
catalogue.apply(medicine);
const isStale = hadMedicine && oldWithdrawal !== "" && oldWithdrawal !== known;
setStaleWithdrawal(isStale ? (known ?? null) : undefined);
}
function chooseLot(lotId: string) {
choice.decideLot();
setMedicineLotId(lotId);
}
const mutation = useMutation({
mutationFn: () =>
updateTreatment(treatment.id, {
@@ -62,17 +155,22 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
date: toApiDate(date),
startDate: emptyToNull(startDate, toApiDate),
endDate: emptyToNull(endDate, toApiDate),
product,
activeIngredient: emptyToNull(activeIngredient),
amount: emptyToNull(amount),
method: emptyToNull(method),
reason: emptyToNull(reason),
batchNumber: emptyToNull(batchNumber),
notes: emptyToNull(notes),
withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number),
supplier: emptyToNull(supplier),
purchaseProof: emptyToNull(purchaseProof),
prescribingVet: emptyToNull(prescribingVet),
...medicinePatch(treatment, {
product,
medicineId,
medicineLotId,
doseQuantity,
batchNumber,
supplier,
purchaseProof,
prescribingVet,
}),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -84,10 +182,14 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
}
// A treatment moves the post-treatment window of the varroa lights.
void invalidateVarroaBands(queryClient);
// The server books the change of pack or dose: the rest moves.
if (treatment.medicineLotId || medicineLotId) {
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
}
setOpen(false);
},
onError: (error) =>
setSubmitError(errorMessage(error, "Behandlung konnte nicht gespeichert werden.")),
onError: (error) => setSubmitError(saveErrorText(error, SAVE_FAILED)),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -95,10 +197,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
setSubmitError(null);
const errors: FieldErrors = {
date: date ? undefined : DATE_REQUIRED_MESSAGE,
product: product.trim() ? undefined : "Bitte das verwendete Mittel angeben.",
product: medicineId || product.trim() ? undefined : "Bitte das verwendete Mittel angeben.",
doseQuantity: medicineId ? (doseError(doseQuantity) ?? undefined) : undefined,
};
setFieldErrors(errors);
if (errors.date || errors.product) {
if (errors.date || errors.product || errors.doseQuantity) {
return;
}
mutation.mutate();
@@ -138,30 +241,48 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</FormSection>
<FormSection step={2} title="Mittel">
<Field label="Mittel" required error={fieldErrors.product}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={product}
onChange={(event) => {
setProduct(event.target.value);
setFieldErrors((current) => ({ ...current, product: undefined }));
}}
/>
)}
</Field>
<MedicineLotFields
idPrefix="treatment-edit"
choice={choice}
product={product}
medicineId={medicineId}
medicineLotId={medicineLotId}
doseQuantity={doseQuantity}
productError={fieldErrors.product}
doseError={fieldErrors.doseQuantity}
filled={catalogue.filled}
onProductChange={(next) => {
setProduct(next);
setFieldErrors((current) => ({ ...current, product: undefined }));
}}
onMedicineChange={chooseMedicine}
onLotChange={chooseLot}
onDoseChange={(next) => {
catalogue.edited("doseQuantity");
setDoseQuantity(next);
setFieldErrors((current) => ({ ...current, doseQuantity: undefined }));
}}
/>
{hasLot ? (
<p className="text-xs text-muted-foreground">
Änderungen an Packung oder Dosis werden gegengebucht.
</p>
) : null}
<Field label="Wirkstoff">
{({ id }) => (
<Input
id={id}
className="h-11"
value={activeIngredient}
onChange={(event) => setActiveIngredient(event.target.value)}
/>
<>
<Input
id={id}
className="h-11"
aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.activeIngredient)}
value={activeIngredient}
onChange={(event) => {
catalogue.edited("activeIngredient");
setActiveIngredient(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.activeIngredient} />
</>
)}
</Field>
<Field label="Menge">
@@ -176,12 +297,19 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</Field>
<Field label="Methode">
{({ id }) => (
<Input
id={id}
className="h-11"
value={method}
onChange={(event) => setMethod(event.target.value)}
/>
<>
<Input
id={id}
className="h-11"
aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.method)}
value={method}
onChange={(event) => {
catalogue.edited("method");
setMethod(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.method} />
</>
)}
</Field>
<Field label="Grund">
@@ -223,15 +351,44 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</div>
<Field label="Wartezeit (Tage)">
{({ id }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
<>
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.withdrawalPeriodDays)}
value={withdrawalPeriodDays}
onChange={(event) => {
catalogue.edited("withdrawalPeriodDays");
setStaleWithdrawal(undefined);
setWithdrawalPeriodDays(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.withdrawalPeriodDays} />
<div role="status" aria-live="polite" className="empty:hidden">
{staleWithdrawal !== undefined ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<AlertTriangle className="size-4 shrink-0" aria-hidden />
<span>{STALE_WITHDRAWAL}</span>
{staleWithdrawal !== null ? (
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
setWithdrawalPeriodDays(staleWithdrawal);
setStaleWithdrawal(undefined);
}}
>
aus Katalog übernehmen
</Button>
) : null}
</div>
) : null}
</div>
</>
)}
</Field>
</FormSection>
@@ -241,49 +398,57 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
title="Für das Bestandsbuch"
description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
>
<Field label="Chargennummer">
{({ id }) => (
<Input
id={id}
className="h-11"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
)}
</Field>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
)}
</Field>
<Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend.">
{({ id, describedBy }) => (
<Input
id={id}
className="h-11"
aria-describedby={describedBy}
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
)}
</Field>
{hasLot ? (
<p className="rounded-lg bg-muted/60 px-3 py-2 text-sm">
{choice.lot ? lotSourceLine(choice.lot) : "aus Packung"}
</p>
) : (
<>
<Field label="Chargennummer">
{({ id }) => (
<Input
id={id}
className="h-11"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
)}
</Field>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
)}
</Field>
<Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend.">
{({ id, describedBy }) => (
<Input
id={id}
className="h-11"
aria-describedby={describedBy}
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
)}
</Field>
</>
)}
</FormSection>
<FormSection
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewColonyPage from "./page";
import { createColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -13,6 +15,15 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/lib/colonies/api", () => ({ createColony: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
function renderPage() {
const client = new QueryClient({
@@ -113,3 +124,81 @@ describe("NewColonyPage – besetzte Waben", () => {
expect(field).toHaveValue("40");
});
});
describe("NewColonyPage – Sperrbezirk", () => {
const REASON = "Amtstierarzt hat das Anlegen erlaubt.";
beforeEach(() => {
permissions = [];
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-1",
apiaryId: "apiary-1",
apiaryName: "Hausgarten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
vi.mocked(createColony).mockReset();
vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never);
});
async function fillRestricted() {
renderPage();
await userEvent.type(screen.getByLabelText(/Code/), "V-09");
await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
}
it("shows the same notice and disables Volk anlegen for a restricted apiary", async () => {
await fillRestricted();
expect(
await screen.findByText(
"Stand Hausgarten liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Anlegen ist gesperrt.",
),
).toBeInTheDocument();
const submit = screen.getByRole("button", { name: "Volk anlegen" });
expect(submit).toHaveAttribute("aria-disabled", "true");
expect(submit.getAttribute("aria-describedby")).toBeTruthy();
});
it("creates the colony with the override reason after the confirmation", async () => {
permissions = ["compliance:manage"];
await fillRestricted();
await userEvent.click(await screen.findByRole("button", { name: /Ausnahme mit Begründung/ }));
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Volk anlegen" })).not.toHaveAttribute("aria-disabled"));
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
expect(vi.mocked(createColony).mock.calls[0][0]).toMatchObject({
code: "V-09",
apiaryId: "apiary-1",
restrictionOverrideReason: REASON,
});
});
it("puts the focus on the server's RESTRICTED_ZONE message", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
vi.mocked(createColony).mockRejectedValue(
new ApiError(409, "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.", {
code: "RESTRICTED_ZONE",
restrictionIds: ["r-1"],
}),
);
await fillRestricted();
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("liegt im Sperrbezirk");
await waitFor(() => expect(alert).toHaveFocus());
});
});
@@ -9,7 +9,6 @@ import { createColony } from "@/lib/colonies/api";
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";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
@@ -22,6 +21,9 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard";
import { BLOCKED_TEXT } from "@/components/compliance/restriction-notice";
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
import {
ColonyFormErrors,
ColonyFormValues,
@@ -42,6 +44,9 @@ export default function NewColonyPage() {
const [dirty, setDirty] = useState(false);
const [errors, setErrors] = useState<ColonyFormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [focusError, setFocusError] = useState(false);
const errorRef = useFocusWhen(submitError !== null && focusError);
const guard = useRestrictionGuard(values.apiaryId ? [values.apiaryId] : []);
const draft = useFormDraft("colony-new", values, dirty);
function set<K extends keyof ColonyFormValues>(
@@ -54,7 +59,8 @@ export default function NewColonyPage() {
}
const mutation = useMutation({
mutationFn: createColony,
mutationFn: (input: ReturnType<typeof toColonyInput>) =>
createColony({ ...input, ...guard.overrideFields() }),
onSuccess: (colony) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -62,14 +68,15 @@ export default function NewColonyPage() {
router.push(`/colonies/${colony.id}`);
},
onError: (mutationError) => {
setSubmitError(
errorMessage(mutationError, "Volk konnte nicht angelegt werden."),
);
const explained = guard.explain(mutationError, "Volk konnte nicht angelegt werden.");
setSubmitError(explained.text);
setFocusError(explained.focus);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!guard.canProceed) return;
setSubmitError(null);
const found = validateColony(values);
setErrors(found);
@@ -223,12 +230,16 @@ export default function NewColonyPage() {
</Field>
</FormSection>
<RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.create} />
<StickySubmit
note={
submitError ? (
<p
ref={errorRef}
role="alert"
className="text-sm font-medium text-destructive"
tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
>
{submitError}
</p>
@@ -239,6 +250,8 @@ export default function NewColonyPage() {
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
>
{mutation.isPending ? "Wird angelegt…" : "Volk anlegen"}
</Button>
@@ -0,0 +1,136 @@
"use client";
import { FormEvent, useState } from "react";
import { Send } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { berlinCalendarDay } from "@/lib/dates";
import { complianceErrorText } from "@/lib/compliance/errors";
import type { AuthorityNotice, MarkSentInput } from "@/lib/compliance/notice-types";
import { noticeAction } from "@/lib/compliance/notices-api";
interface MarkSentDialogProps {
noticeId: string;
onDone: (notice: AuthorityNotice) => void;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
/** The main action of the page (after the PDF was opened) or a secondary one before. */
primary?: boolean;
}
/** The Berlin calendar day as YYYY-MM-DD: the default for „Gesendet am“, whatever the device clock says. */
function todayInput(): string {
return berlinCalendarDay(new Date().toISOString());
}
/** Id of the trigger, so the page can move the focus here once it becomes the main action. */
export const MARK_SENT_TRIGGER_ID = "notice-mark-sent";
/** Step 3: „Als gesendet markieren“ asks for date, channel and reference. */
export function MarkSentDialog({ noticeId, onDone, disabled = false, describedBy, primary = true }: MarkSentDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
id={MARK_SENT_TRIGGER_ID}
size="touch"
variant={primary ? "default" : "outline"}
disabled={disabled}
aria-describedby={describedBy}
>
<Send aria-hidden />
Als gesendet markieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Als gesendet markieren</DialogTitle>
</DialogHeader>
<MarkSentForm
noticeId={noticeId}
onDone={(notice) => {
setOpen(false);
onDone(notice);
}}
/>
</DialogContent>
</Dialog>
);
}
function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice: AuthorityNotice) => void }) {
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [sentOn, setSentOn] = useState(todayInput);
const [sentVia, setSentVia] = useState("");
const [reference, setReference] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () => {
const body: MarkSentInput = {
...(sentOn ? { sentOn } : {}),
...(sentVia.trim() ? { sentVia: sentVia.trim() } : {}),
...(reference.trim() ? { reference: reference.trim() } : {}),
};
return noticeAction(noticeId, "mark-sent", body);
},
onSuccess: onDone,
onError: (mutationError) =>
setError(complianceErrorText(mutationError, "Die Anzeige wurde nicht als gesendet markiert.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-on">Gesendet am</Label>
<Input id="sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-via">Gesendet über</Label>
<Input
id="sent-via"
value={sentVia}
maxLength={300}
placeholder="z. B. E-Mail, Post"
onChange={(event) => setSentVia(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-reference">Aktenzeichen</Label>
<Input
id="sent-reference"
value={reference}
maxLength={300}
placeholder="falls das Amt eins nennt"
onChange={(event) => setReference(event.target.value)}
/>
</div>
<div aria-live="assertive">
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Speichert…" : "Bestätigen"}
</Button>
</form>
);
}
@@ -0,0 +1,209 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { FormSection } from "@/components/form/form-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { complianceErrorText } from "@/lib/compliance/errors";
import { noticeDay } from "@/lib/compliance/notice-labels";
import type { AuthorityNotice, NoticeEdit } from "@/lib/compliance/notice-types";
import { updateNotice } from "@/lib/compliance/notices-api";
const COUNT_PROBLEM = "Bitte eine ganze Zahl ab 0 eingeben.";
interface FormValues {
totalColonies: string;
referenceDate: string;
sentOn: string;
sentVia: string;
reference: string;
notes: string;
}
function valuesOf(notice: AuthorityNotice): FormValues {
return {
totalColonies: String(notice.totalColonies),
referenceDate: noticeDay(notice.referenceDate) ?? "",
sentOn: noticeDay(notice.sentOn) ?? "",
sentVia: notice.sentVia ?? "",
reference: notice.reference ?? "",
notes: notice.notes ?? "",
};
}
/** Empty text means „remove“: the server stores null. */
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
const DATE_PROBLEM = "Das Datum kann nicht entfernt werden.";
interface Edit {
changes: Partial<NoticeEdit>;
/** Messages per field name; shown at the field while they apply. */
problems: Partial<Record<keyof FormValues, string>>;
}
/** Only what the person changed; a day can be corrected, never removed. */
function editOf(notice: AuthorityNotice, values: FormValues): Edit {
const before = valuesOf(notice);
const changes: Partial<NoticeEdit> = {};
const problems: Edit["problems"] = {};
if (values.totalColonies !== before.totalColonies) {
const count = Number(values.totalColonies.trim());
if (values.totalColonies.trim() === "" || !Number.isInteger(count) || count < 0) {
problems.totalColonies = COUNT_PROBLEM;
} else {
changes.totalColonies = count;
}
}
if (values.referenceDate === "") problems.referenceDate = DATE_PROBLEM;
else if (values.referenceDate !== before.referenceDate) changes.referenceDate = values.referenceDate;
if (notice.status === "SENT") {
if (values.sentOn === "") problems.sentOn = DATE_PROBLEM;
else if (values.sentOn !== before.sentOn) changes.sentOn = values.sentOn;
if (values.sentVia !== before.sentVia) changes.sentVia = textOrNull(values.sentVia);
if (values.reference !== before.reference) changes.reference = textOrNull(values.reference);
}
if (values.notes !== before.notes) changes.notes = textOrNull(values.notes);
return { changes, problems };
}
function FieldProblem({ id, text }: { id: string; text: string | undefined }) {
if (!text) return null;
return (
<p id={id} role="alert" className="text-sm text-destructive">
{text}
</p>
);
}
interface NoticeEditFormProps {
notice: AuthorityNotice;
onSaved: (notice: AuthorityNotice) => void;
}
/** Corrections after the fact: the shown count, the day and the notes stay editable. */
export function NoticeEditForm({ notice, onSaved }: NoticeEditFormProps) {
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [values, setValues] = useState<FormValues>(() => valuesOf(notice));
const [error, setError] = useState<string | null>(null);
const isSent = notice.status === "SENT";
const { changes, problems } = editOf(notice, values);
const hasProblems = Object.keys(problems).length > 0;
const hasChanges = Object.keys(changes).length > 0;
const mutation = useMutation({
mutationFn: (edit: Partial<NoticeEdit>) => updateNotice(notice.id, edit),
onSuccess: onSaved,
onError: (mutationError) => setError(complianceErrorText(mutationError, "Die Änderungen wurden nicht gespeichert.")),
});
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (hasProblems || isOffline || !hasChanges) return;
mutation.mutate(changes);
}
return (
<form onSubmit={handleSubmit} noValidate>
<FormSection title="Angaben ändern" collapsible defaultOpen={false} fieldCount={isSent ? 6 : 3}>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-total">Angezeigte Völker</Label>
<Input
id="edit-total"
aria-invalid={problems.totalColonies ? true : undefined}
aria-describedby={problems.totalColonies ? "problem-totalColonies" : undefined}
inputMode="numeric"
value={values.totalColonies}
onChange={(event) => set("totalColonies", event.target.value)}
/>
<FieldProblem id="problem-totalColonies" text={problems.totalColonies} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-reference-date">Stichtag</Label>
<Input
id="edit-reference-date"
aria-invalid={problems.referenceDate ? true : undefined}
aria-describedby={problems.referenceDate ? "problem-referenceDate" : undefined}
type="date"
value={values.referenceDate}
onChange={(event) => set("referenceDate", event.target.value)}
/>
<FieldProblem id="problem-referenceDate" text={problems.referenceDate} />
</div>
{isSent ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-sent-on">Gesendet am</Label>
<Input
id="edit-sent-on"
aria-invalid={problems.sentOn ? true : undefined}
aria-describedby={problems.sentOn ? "problem-sentOn" : undefined}
type="date"
value={values.sentOn}
onChange={(event) => set("sentOn", event.target.value)}
/>
<FieldProblem id="problem-sentOn" text={problems.sentOn} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-sent-via">Gesendet über</Label>
<Input
id="edit-sent-via"
value={values.sentVia}
maxLength={300}
onChange={(event) => set("sentVia", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-reference">Aktenzeichen</Label>
<Input
id="edit-reference"
value={values.reference}
maxLength={300}
onChange={(event) => set("reference", event.target.value)}
/>
</div>
</>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-notes">Notizen</Label>
<Textarea
id="edit-notes"
value={values.notes}
maxLength={2000}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
<div aria-live="assertive">
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
variant="outline"
disabled={mutation.isPending || isOffline || !hasChanges || hasProblems}
aria-describedby={describedBy}
>
{mutation.isPending ? "Speichert…" : "Angaben speichern"}
</Button>
</FormSection>
</form>
);
}
@@ -0,0 +1,50 @@
import { COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import type { ColonyType } from "@/lib/colonies/types";
import type { AuthorityNotice, HerdApiarySnapshot } from "@/lib/compliance/notice-types";
function coloniesText(count: number): string {
return `${count} ${count === 1 ? "Volk" : "Völker"}`;
}
function typesText(byType: HerdApiarySnapshot["byType"]): string {
return (Object.entries(byType) as [ColonyType, number][])
.filter(([, count]) => count > 0)
.map(([type, count]) => `${COLONY_TYPE_LABELS[type] ?? type}: ${count}`)
.join(", ");
}
function placeOf(apiary: HerdApiarySnapshot): string {
return [apiary.address, apiary.location].filter(Boolean).join(", ");
}
/** The herd as it was counted: the shown total and every apiary with its colonies. */
export function NoticeHerd({ notice }: { notice: AuthorityNotice }) {
const { snapshot } = notice;
return (
<section aria-labelledby="notice-herd-heading" className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-4">
<h2 id="notice-herd-heading" className="font-heading text-lg font-semibold tracking-tight">
Bestand zum Stichtag
</h2>
<p className="text-sm font-medium">{coloniesText(notice.totalColonies)} angezeigt</p>
{snapshot.totalLiving !== notice.totalColonies ? (
<p className="text-sm text-muted-foreground">Gezählt waren {coloniesText(snapshot.totalLiving)}.</p>
) : null}
{snapshot.apiaries.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Stände im Bestand.</p>
) : (
<ul className="flex flex-col gap-2">
{snapshot.apiaries.map((apiary) => (
<li key={apiary.apiaryId} className="flex flex-col gap-0.5 rounded-lg bg-muted/50 p-3 text-sm">
<span className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium">{apiary.name}</span>
<span>{coloniesText(apiary.livingColonies)}</span>
</span>
{placeOf(apiary) ? <span className="text-muted-foreground">{placeOf(apiary)}</span> : null}
{typesText(apiary.byType) ? <span className="text-muted-foreground">{typesText(apiary.byType)}</span> : null}
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { ArrowRight, Check, Circle, type LucideIcon } from "lucide-react";
import type { ComplianceSettings } from "@/lib/compliance/notice-types";
type StepState = "done" | "current" | "upcoming";
const STEP_ICON: Record<StepState, LucideIcon> = {
done: Check,
current: ArrowRight,
upcoming: Circle,
};
const STEP_WORD: Record<StepState, string> = {
done: "Erledigt",
current: "Jetzt",
upcoming: "Danach",
};
interface StepProps {
number: number;
title: string;
state: StepState;
children?: React.ReactNode;
}
function Step({ number, title, state, children }: StepProps) {
const Icon = STEP_ICON[state];
return (
<li
aria-current={state === "current" ? "step" : undefined}
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{`${number}. ${title}`}</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
<Icon className="size-4" aria-hidden />
{STEP_WORD[state]}
</span>
</span>
{children ? <span className="text-sm text-muted-foreground">{children}</span> : null}
</li>
);
}
function OfficeText({ settings, canManage }: { settings: ComplianceSettings | undefined; canManage: boolean }) {
const name = settings?.veterinaryOfficeName?.trim();
if (!name) {
return (
<>
Schick das PDF an dein Veterinäramt.{" "}
{canManage ? (
<Link href="/compliance/settings" className="underline underline-offset-2">
Veterinäramt eintragen
</Link>
) : null}
</>
);
}
const address = settings?.veterinaryOfficeAddress?.trim();
return <>{`Schick das PDF an ${name}${address ? `, ${address}` : ""}.`}</>;
}
interface NoticeStepsProps {
pdfOpened: boolean;
settings: ComplianceSettings | undefined;
canManage: boolean;
}
/** The way through a draft: open the PDF, send it, confirm here. Symbol plus word per step. */
export function NoticeSteps({ pdfOpened, settings, canManage }: NoticeStepsProps) {
return (
<ol aria-label="Schritte" className="flex flex-col gap-2">
<Step number={1} title="PDF öffnen" state={pdfOpened ? "done" : "current"}>
Prüf die Angaben im PDF.
</Step>
<Step number={2} title="Ans Amt schicken" state={pdfOpened ? "current" : "upcoming"}>
<OfficeText settings={settings} canManage={canManage} />
</Step>
<Step number={3} title="Hier bestätigen" state="upcoming">
Markiere die Anzeige als gesendet, sobald sie raus ist.
</Step>
</ol>
);
}
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NoticeDetailPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { noticeFixture, settingsFixture } from "@/lib/compliance/notice-fixtures";
import { fetchNotice, noticeAction, openNoticePdf, updateNotice } from "@/lib/compliance/notices-api";
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "n1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/notices-api", () => ({
fetchNotice: vi.fn(),
noticeAction: vi.fn(),
openNoticePdf: vi.fn(),
updateNotice: vi.fn(),
}));
vi.mock("@/lib/compliance/settings-api", () => ({ fetchComplianceSettings: vi.fn() }));
vi.mock("@/components/document-upload-card", () => ({
DocumentUploadCard: ({ entityType, entityId, canWrite }: { entityType: string; entityId: string; canWrite?: boolean }) => (
<p>
Upload {entityType} {entityId} {canWrite ? "schreiben" : "nur lesen"}
</p>
),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NoticeDetailPage />
</QueryClientProvider>,
);
}
function steps() {
return within(screen.getByRole("list", { name: "Schritte" })).getAllByRole("listitem");
}
describe("NoticeDetailPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchNotice).mockReset();
vi.mocked(noticeAction).mockReset();
vi.mocked(openNoticePdf).mockReset();
vi.mocked(updateNotice).mockReset();
vi.mocked(fetchComplianceSettings).mockReset();
vi.mocked(fetchComplianceSettings).mockResolvedValue(settingsFixture());
vi.mocked(openNoticePdf).mockResolvedValue(undefined);
});
it("shows three steps: 1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await screen.findByRole("list", { name: "Schritte" });
let items = steps();
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent("1. PDF öffnen");
expect(items[1]).toHaveTextContent("2. Ans Amt schicken");
expect(items[2]).toHaveTextContent("3. Hier bestätigen");
// Symbol plus word per step, never colour alone.
expect(items[0]).toHaveTextContent("Jetzt");
expect(items[1]).toHaveTextContent("Danach");
for (const item of items) expect(item.querySelector("svg")).not.toBeNull();
expect(await within(items[1]).findByText(/Veterinäramt Musterstadt/)).toBeInTheDocument();
// Step 1 is current: the primary button opens the PDF, marking as sent is only secondary.
expect(screen.getByRole("button", { name: "Als gesendet markieren" })).not.toHaveClass("bg-primary");
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
expect(openNoticePdf).toHaveBeenCalledWith("n1");
// Now the main action is step 3, it has the focus and a polite message says what to do.
const mark = await screen.findByRole("button", { name: "Als gesendet markieren" });
expect(mark).toHaveClass("bg-primary");
expect(mark).toHaveFocus();
expect(screen.getByRole("status")).toHaveTextContent("PDF heruntergeladen. Schick es jetzt ans Amt.");
expect(screen.queryByRole("button", { name: "PDF öffnen" })).not.toBeInTheDocument();
items = steps();
expect(items[0]).toHaveTextContent("Erledigt");
expect(items[1]).toHaveTextContent("Jetzt");
});
it("offers Als gesendet markieren even after a reload, without opening the PDF again", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(openNoticePdf).not.toHaveBeenCalled();
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", expect.any(Object));
});
it("defaults Gesendet am to the Berlin day, not the device day", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2027-03-01T23:30:00Z"));
try {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
expect(screen.getByLabelText("Gesendet am")).toHaveValue("2027-03-02");
} finally {
vi.useRealTimers();
}
});
it("shows German text when the PDF download fails with 403 or a server error", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(403, "Forbidden resource"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(500, "Internal server error"));
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
expect(await screen.findByText(/Der Server hat einen Fehler gemeldet/)).toBeInTheDocument();
expect(screen.queryByText("Dazu fehlt dir das Recht.")).not.toBeInTheDocument();
});
it("a cleared reference day or sent day shows that it cannot be removed", async () => {
vi.mocked(fetchNotice).mockResolvedValue(
noticeFixture({ status: "SENT", sentOn: "2027-01-15T00:00:00.000Z" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: /Angaben ändern/ }));
fireEvent.change(screen.getByLabelText("Stichtag"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "" } });
const alerts = screen.getAllByRole("alert");
expect(alerts.map((alert) => alert.textContent)).toEqual([
"Das Datum kann nicht entfernt werden.",
"Das Datum kann nicht entfernt werden.",
]);
expect(screen.getByLabelText("Stichtag")).toHaveAccessibleDescription("Das Datum kann nicht entfernt werden.");
expect(screen.getByRole("button", { name: "Angaben speichern" })).toBeDisabled();
});
it("Als gesendet markieren asks for date, channel and reference and sends them", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2027-01-15" } });
await userEvent.type(screen.getByLabelText("Gesendet über"), "E-Mail");
await userEvent.type(screen.getByLabelText("Aktenzeichen"), "AZ 12/27");
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", {
sentOn: "2027-01-15",
sentVia: "E-Mail",
reference: "AZ 12/27",
});
});
it("shows the server message when marking as sent fails", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockRejectedValue(new ApiError(409, "Nur ein Entwurf kann gesendet werden.", { message: "Nur ein Entwurf kann gesendet werden." }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nur ein Entwurf kann gesendet werden.");
});
it("recount is offered only for drafts and cancel only for drafts", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "DRAFT" }));
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ totalColonies: 9 }));
const { unmount } = renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Neu zählen" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "recount");
expect(screen.getByRole("button", { name: "Anzeige stornieren" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Wieder öffnen" })).not.toBeInTheDocument();
unmount();
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "SENT", sentOn: "2027-01-15T00:00:00.000Z", sentVia: "E-Mail", reference: "AZ 1" }));
const sent = renderPage();
expect(await screen.findByRole("button", { name: "Wieder öffnen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Neu zählen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Anzeige stornieren" })).not.toBeInTheDocument();
expect(screen.getByText(/Gesendet am 15\.01\.2027/)).toBeInTheDocument();
sent.unmount();
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "CANCELLED" }));
renderPage();
await screen.findByText("Storniert");
expect(screen.queryByRole("button", { name: "Neu zählen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Anzeige stornieren" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Wieder öffnen" })).not.toBeInTheDocument();
});
it("cancelling asks first and then posts the cancel action", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "CANCELLED" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Anzeige stornieren" }));
expect(noticeAction).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Ja, stornieren" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "cancel");
});
it("lets a manager correct the shown count and sends only what changed", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ totalColonies: 7 }));
vi.mocked(updateNotice).mockResolvedValue(noticeFixture({ totalColonies: 9 }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: /Angaben ändern/ }));
const count = screen.getByLabelText("Angezeigte Völker");
await userEvent.clear(count);
await userEvent.type(count, "9");
await userEvent.click(screen.getByRole("button", { name: "Angaben speichern" }));
expect(updateNotice).toHaveBeenCalledWith("n1", { totalColonies: 9 });
});
it("attaches copy and receipt to the notice, writable only for a manager", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
expect(await screen.findByText("Upload AUTHORITY_NOTICE n1 schreiben")).toBeInTheDocument();
});
it("the herd of the notice is listed per apiary", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
const region = await screen.findByRole("region", { name: "Bestand zum Stichtag" });
expect(region).toHaveTextContent("Waldrand");
expect(region).toHaveTextContent("7 Völker");
expect(region).toHaveTextContent("Wirtschaftsvolk: 5");
expect(region).toHaveTextContent("Ableger: 2");
});
it("STAFF sees the page read-only without action buttons", async () => {
permissions = ["compliance:read"];
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await screen.findByRole("list", { name: "Schritte" });
for (const name of ["Als gesendet markieren", "Neu zählen", "Anzeige stornieren", "Wieder öffnen", "Angaben ändern"]) {
expect(screen.queryByRole("button", { name: new RegExp(name) })).not.toBeInTheDocument();
}
expect(screen.getByText("Upload AUTHORITY_NOTICE n1 nur lesen")).toBeInTheDocument();
// Reading the PDF stays possible.
expect(screen.getByRole("button", { name: "PDF öffnen" })).toBeInTheDocument();
});
});
@@ -0,0 +1,239 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { BookOpen, FileText, RotateCcw } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { complianceErrorText } from "@/lib/compliance/errors";
import { noticeDay, noticeTitle } from "@/lib/compliance/notice-labels";
import type { AuthorityNotice } from "@/lib/compliance/notice-types";
import { fetchNotice, noticeAction, openNoticePdf } from "@/lib/compliance/notices-api";
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { formatDay } from "@/lib/medicines/format";
import { MARK_SENT_TRIGGER_ID, MarkSentDialog } from "./mark-sent-dialog";
import { NoticeEditForm } from "./notice-edit-form";
import { NoticeHerd } from "./notice-herd";
import { NoticeSteps } from "./notice-steps";
type SimpleAction = "recount" | "reopen" | "cancel";
function doneMessage(action: SimpleAction, notice: AuthorityNotice): string {
if (action === "recount") return `Neu gezählt: ${notice.totalColonies} Völker.`;
if (action === "reopen") return "Wieder geöffnet. Die Anzeige ist wieder ein Entwurf.";
return "Anzeige storniert.";
}
function sentText(notice: AuthorityNotice): string {
const parts = [`Gesendet am ${formatDay(noticeDay(notice.sentOn))}`];
if (notice.sentVia) parts.push(`über ${notice.sentVia}`);
if (notice.reference) parts.push(`Aktenzeichen ${notice.reference}`);
return parts.join(" · ");
}
export default function NoticeDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const canManage = hasPermission("compliance:manage");
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [pdfOpened, setPdfOpened] = useState(false);
const [message, setMessage] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
const flowHeading = useRef<HTMLHeadingElement>(null);
const noticeKey = [...complianceKey, "notices", params.id];
const query = useQuery({ queryKey: noticeKey, queryFn: () => fetchNotice(params.id), enabled: canRead });
const notice = query.data;
const settingsQuery = useQuery({
queryKey: [...complianceKey, "settings"],
queryFn: fetchComplianceSettings,
enabled: canRead && notice?.status === "DRAFT",
retry: false,
});
function applyNotice(next: AuthorityNotice, text: string) {
queryClient.setQueryData(noticeKey, next);
void queryClient.invalidateQueries({ queryKey: complianceKey });
setActionError(null);
setMessage(text);
flowHeading.current?.focus();
}
const pdf = useMutation({
mutationFn: () => openNoticePdf(params.id),
onMutate: () => setActionError(null),
onSuccess: () => {
setPdfOpened(true);
setMessage("PDF heruntergeladen. Schick es jetzt ans Amt.");
},
onError: (error) => setActionError(complianceErrorText(error, "Das PDF konnte nicht geladen werden.")),
});
const simple = useMutation({
mutationFn: (action: SimpleAction) => noticeAction(params.id, action),
onMutate: () => setActionError(null),
onSuccess: (next, action) => {
if (action === "reopen") setPdfOpened(false);
applyNotice(next, doneMessage(action, next));
},
onError: (error) => setActionError(complianceErrorText(error, "Das hat nicht geklappt.")),
});
// Once the PDF is out, "Als gesendet markieren" becomes the main action: the focus follows.
useEffect(() => {
if (pdfOpened) document.getElementById(MARK_SENT_TRIGGER_ID)?.focus();
}, [pdfOpened]);
if (!canRead) return <NoComplianceAccess />;
if (query.isLoading || query.isError || !notice) {
return (
<DetailQueryFallback query={query} what="Die Anzeige" backHref="/compliance/notices" backLabel="Alle Anzeigen" />
);
}
const isDraft = notice.status === "DRAFT";
const offlineReason = isOffline ? describedBy : undefined;
function pdfButton() {
return (
<Button size="touch" disabled={isOffline || pdf.isPending} aria-describedby={offlineReason} onClick={() => pdf.mutate()}>
<FileText aria-hidden />
PDF öffnen
</Button>
);
}
function primaryAction() {
if (isDraft && canManage && pdfOpened) {
return (
<MarkSentDialog
noticeId={notice!.id}
disabled={isOffline}
describedBy={offlineReason}
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
/>
);
}
return notice!.status === "CANCELLED" ? undefined : pdfButton();
}
const flowTitle = isDraft ? "Ablauf" : notice.status === "SENT" ? "Versand" : "Verworfen";
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
title={noticeTitle(notice)}
subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`}
status={<NoticeStatus status={notice.status} />}
primaryAction={primaryAction()}
moreActions={
isDraft && pdfOpened
? [{ label: "PDF nochmal öffnen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }]
: undefined
}
backHref="/compliance/notices"
backLabel="Alle Anzeigen"
/>
<OnlineOnlyHint id={hintId} />
<div role="status" aria-live="polite" className="min-h-5 text-sm font-medium">
{message}
</div>
<div aria-live="assertive">
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
</div>
<section aria-labelledby="notice-flow-heading" className="flex flex-col gap-3">
<h2
id="notice-flow-heading"
ref={flowHeading}
tabIndex={-1}
className="font-heading text-lg font-semibold tracking-tight outline-none"
>
{flowTitle}
</h2>
{isDraft ? (
<NoticeSteps pdfOpened={pdfOpened} settings={settingsQuery.data} canManage={canManage} />
) : null}
{notice.status === "SENT" ? <p className="text-sm">{sentText(notice)}</p> : null}
{notice.status === "CANCELLED" ? (
<p className="text-sm text-muted-foreground">Diese Anzeige wurde verworfen. Es wurde nichts gesendet.</p>
) : null}
{canManage && isDraft ? (
<div className="flex flex-wrap gap-2">
{pdfOpened ? null : (
<MarkSentDialog
noticeId={notice.id}
primary={false}
disabled={isOffline}
describedBy={offlineReason}
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
/>
)}
<Button
variant="outline"
size="touch"
disabled={isOffline || simple.isPending}
aria-describedby={offlineReason}
onClick={() => simple.mutate("recount")}
>
<RotateCcw aria-hidden />
Neu zählen
</Button>
<ConfirmDialog
triggerLabel="Anzeige stornieren"
triggerSize="touch"
triggerDisabled={isOffline}
triggerDescribedBy={offlineReason}
title="Anzeige stornieren?"
description="Der Entwurf wird verworfen. Gesendet wurde nichts. Du kannst danach eine neue Anzeige anlegen."
confirmLabel="Ja, stornieren"
isPending={simple.isPending}
onConfirm={() => simple.mutate("cancel")}
/>
</div>
) : null}
{canManage && notice.status === "SENT" ? (
<div>
<ConfirmDialog
triggerLabel="Wieder öffnen"
triggerSize="touch"
triggerDisabled={isOffline}
triggerDescribedBy={offlineReason}
title="Anzeige wieder öffnen?"
description="Die Anzeige wird wieder ein Entwurf, das Sendedatum entfällt. Danach kannst du sie neu zählen und erneut senden."
confirmLabel="Ja, wieder öffnen"
destructive={false}
isPending={simple.isPending}
onConfirm={() => simple.mutate("reopen")}
/>
</div>
) : null}
</section>
<NoticeHerd notice={notice} />
{canManage && notice.status !== "CANCELLED" ? (
<NoticeEditForm
key={[notice.totalColonies, notice.referenceDate, notice.sentOn, notice.sentVia, notice.reference, notice.notes].join("|")}
notice={notice}
onSaved={(next) => applyNotice(next, "Änderungen gespeichert.")}
/>
) : null}
<DocumentUploadCard
entityType="AUTHORITY_NOTICE"
entityId={notice.id}
title="Kopie und Eingangsbestätigung"
canWrite={canManage}
/>
</main>
);
}
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateNoticeDialog } from "./create-notice-dialog";
import { ApiError } from "@/lib/auth/api-client";
import { createNotice } from "@/lib/compliance/notices-api";
import { noticeFixture } from "@/lib/compliance/notice-fixtures";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/notices-api", () => ({ createNotice: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<CreateNoticeDialog />
</QueryClientProvider>,
);
}
async function open() {
await userEvent.click(screen.getByRole("button", { name: "Anzeige anlegen" }));
}
describe("CreateNoticeDialog", () => {
beforeEach(() => {
push.mockReset();
vi.mocked(createNotice).mockReset();
});
it('"bereits angezeigt am …" creates a SENT registration with the entered date', async () => {
vi.mocked(createNotice).mockResolvedValue(noticeFixture({ id: "n9", kind: "REGISTRATION", status: "SENT" }));
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Erstanzeige" }));
await userEvent.click(screen.getByRole("checkbox", { name: "Bereits angezeigt" }));
fireEvent.change(screen.getByLabelText("Angezeigt am"), { target: { value: "2026-05-01" } });
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).toHaveBeenCalledWith(
{ kind: "REGISTRATION", sentOn: "2026-05-01" },
{ idempotencyKey: expect.stringMatching(/^[A-Za-z0-9_-]{16,128}$/) },
);
expect(push).toHaveBeenCalledWith("/compliance/notices/n9");
});
it("a TSK count needs a year and sends it", async () => {
vi.mocked(createNotice).mockResolvedValue(noticeFixture({ id: "n3", kind: "TSK_REPORT", year: 2027 }));
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "TSK-Zählung" }));
const year = screen.getByLabelText("Jahr");
await userEvent.clear(year);
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).not.toHaveBeenCalled();
expect(screen.getByText("Bitte ein Jahr eingeben.")).toBeInTheDocument();
await userEvent.type(year, "2027");
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).toHaveBeenCalledWith({ kind: "TSK_REPORT", year: 2027 }, expect.any(Object));
});
it("a 409 DRAFT_EXISTS links to the existing draft", async () => {
vi.mocked(createNotice).mockRejectedValue(
new ApiError(409, "Es gibt schon einen Entwurf.", {
code: "DRAFT_EXISTS",
existingId: "n7",
message: "Es gibt schon einen Entwurf.",
}),
);
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Bestandsänderung" }));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Es gibt schon einen Entwurf.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zum vorhandenen Entwurf" })).toHaveAttribute(
"href",
"/compliance/notices/n7",
);
expect(push).not.toHaveBeenCalled();
});
it("shows any other server message, for example the missing first registration", async () => {
vi.mocked(createNotice).mockRejectedValue(
new ApiError(409, "Erst die Erstanzeige erfassen.", { message: "Erst die Erstanzeige erfassen." }),
);
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Bestandsänderung" }));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
});
});
@@ -0,0 +1,221 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { complianceErrorText, draftExists } from "@/lib/compliance/errors";
import { NOTICE_KINDS, NOTICE_KIND_LABEL } from "@/lib/compliance/notice-labels";
import type { AuthorityNoticeKind, CreateNoticeInput } from "@/lib/compliance/notice-types";
import { createNotice } from "@/lib/compliance/notices-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
const YEAR_PROBLEM = "Bitte ein Jahr eingeben.";
const SENT_ON_PROBLEM = "Bitte das Datum der Anzeige eingeben.";
const KIND_HINT: Record<AuthorityNoticeKind, string> = {
REGISTRATION: "Deine erste Meldung an das Veterinäramt.",
HERD_CHANGE: "Wenn sich Völker oder Stände geändert haben.",
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
};
interface CreateNoticeDialogProps {
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/** „Anzeige anlegen“: one draft per kind and year, or a notice that was sent earlier. */
export function CreateNoticeDialog({ disabled = false, describedBy }: CreateNoticeDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="touch" disabled={disabled} aria-describedby={describedBy}>
<Plus aria-hidden />
Anzeige anlegen
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Anzeige anlegen</DialogTitle>
</DialogHeader>
<CreateNoticeForm onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
function problemWith(kind: AuthorityNoticeKind, year: string, alreadySent: boolean, sentOn: string): string | null {
if (kind === "TSK_REPORT" && (year.trim() === "" || !Number.isInteger(Number(year.trim())))) {
return YEAR_PROBLEM;
}
if (alreadySent && sentOn === "") return SENT_ON_PROBLEM;
return null;
}
interface SubmitError {
text: string;
existingId: string | null;
}
function CreateNoticeForm({ onDone }: { onDone: () => void }) {
const router = useRouter();
const queryClient = useQueryClient();
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
const [year, setYear] = useState(String(new Date().getFullYear()));
const [alreadySent, setAlreadySent] = useState(false);
const [sentOn, setSentOn] = useState("");
const [referenceDate, setReferenceDate] = useState("");
const [showProblem, setShowProblem] = useState(false);
const [error, setError] = useState<SubmitError | null>(null);
const errorRef = useRef<HTMLDivElement>(null);
const problem = problemWith(kind, year, alreadySent, sentOn);
useEffect(() => {
if (error) errorRef.current?.focus();
}, [error]);
function buildInput(): CreateNoticeInput {
return {
kind,
...(kind === "TSK_REPORT" ? { year: Number(year.trim()) } : {}),
...(alreadySent ? { sentOn } : {}),
...(referenceDate ? { referenceDate } : {}),
};
}
const mutation = useMutation({
mutationFn: () => {
const input = buildInput();
return createNotice(input, { idempotencyKey: attempt.keyFor(input) });
},
onSuccess: (notice) => {
void queryClient.invalidateQueries({ queryKey: complianceKey });
attempt.done();
onDone();
router.push(`/compliance/notices/${encodeURIComponent(notice.id)}`);
},
onError: (mutationError) => {
attempt.failed(mutationError);
const existing = draftExists(mutationError);
setError(
existing
? { text: existing.message || "Es gibt schon einen Entwurf.", existingId: existing.existingId }
: { text: complianceErrorText(mutationError, "Die Anzeige wurde nicht angelegt."), existingId: null },
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setShowProblem(true);
if (problem !== null || isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
{NOTICE_KINDS.map((entry) => (
<label
key={entry}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-primary has-[:checked]:bg-primary/10"
>
<input
type="radio"
name="notice-kind"
value={entry}
checked={kind === entry}
onChange={() => setKind(entry)}
aria-labelledby={`notice-kind-${entry}`}
aria-describedby={`notice-kind-hint-${entry}`}
className="mt-1 size-4"
/>
<span className="flex flex-col">
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
{NOTICE_KIND_LABEL[entry]}
</span>
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
{KIND_HINT[entry]}
</span>
</span>
</label>
))}
</fieldset>
{kind === "TSK_REPORT" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="notice-year">Jahr</Label>
<Input
id="notice-year"
inputMode="numeric"
value={year}
aria-invalid={showProblem && problem === YEAR_PROBLEM}
onChange={(event) => setYear(event.target.value)}
/>
</div>
) : null}
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
{alreadySent ? (
<div className="flex flex-col gap-2">
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
<Input id="notice-sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
</div>
) : null}
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={1}>
<div className="flex flex-col gap-2">
<Label htmlFor="notice-reference-date">Stichtag</Label>
<Input
id="notice-reference-date"
type="date"
value={referenceDate}
onChange={(event) => setReferenceDate(event.target.value)}
/>
<p className="text-xs text-muted-foreground">Leer lassen: heute.</p>
</div>
</FormSection>
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
{showProblem ? problem : null}
</p>
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none">
{error ? (
<div className="flex flex-col gap-2 text-sm text-destructive">
<p>{error.text}</p>
{error.existingId ? (
<Link
href={`/compliance/notices/${encodeURIComponent(error.existingId)}`}
className="inline-flex min-h-11 items-center self-start underline underline-offset-2"
>
Zum vorhandenen Entwurf
</Link>
) : null}
</div>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Legt an…" : "Anlegen"}
</Button>
</form>
);
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NoticesPage from "./page";
import { fetchNotices } from "@/lib/compliance/notices-api";
import { noticeFixture } from "@/lib/compliance/notice-fixtures";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/lib/compliance/notices-api", () => ({ fetchNotices: vi.fn(), createNotice: vi.fn() }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NoticesPage />
</QueryClientProvider>,
);
}
describe("NoticesPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchNotices).mockReset();
});
it("the list shows kind, year, status, count and reference date as real links", async () => {
vi.mocked(fetchNotices).mockResolvedValue([
noticeFixture({ id: "n1", kind: "TSK_REPORT", year: 2027, status: "SENT", totalColonies: 12, referenceDate: "2027-01-01T00:00:00.000Z" }),
noticeFixture({ id: "n2", kind: "HERD_CHANGE", status: "DRAFT", totalColonies: 7, referenceDate: "2027-03-05T00:00:00.000Z" }),
]);
renderPage();
const first = await screen.findByRole("link", { name: /TSK-Zählung 2027/ });
expect(first).toHaveAttribute("href", "/compliance/notices/n1");
expect(within(first).getByText("Gesendet")).toBeInTheDocument();
expect(first).toHaveTextContent("12 Völker");
expect(first).toHaveTextContent("Stichtag 01.01.2027");
expect(first.querySelector("svg")).not.toBeNull();
const second = screen.getByRole("link", { name: /Bestandsänderung/ });
expect(second).toHaveAttribute("href", "/compliance/notices/n2");
expect(within(second).getByText("Entwurf")).toBeInTheDocument();
expect(second).toHaveTextContent("Stichtag 05.03.2027");
});
it("filters by kind through the server query", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
renderPage();
await screen.findByText(/Noch keine Anzeige/);
await userEvent.selectOptions(screen.getByLabelText("Art"), "TSK_REPORT");
expect(fetchNotices).toHaveBeenLastCalledWith({ kind: "TSK_REPORT" });
});
it("filters by year through the server query and flags a year that is not four digits", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
renderPage();
await screen.findByText(/Noch keine Anzeige/);
await userEvent.type(screen.getByLabelText("Jahr"), "20");
expect(screen.getByText("Bitte vier Ziffern eingeben, zum Beispiel 2027.")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Jahr"), "27");
expect(fetchNotices).toHaveBeenLastCalledWith({ year: 2027 });
expect(screen.queryByText(/Bitte vier Ziffern/)).not.toBeInTheDocument();
});
it("says the list is capped when the server returned the newest 200", async () => {
const many = Array.from({ length: 200 }, (_, index) => noticeFixture({ id: `n${index}` }));
vi.mocked(fetchNotices).mockResolvedValue(many);
renderPage();
expect(await screen.findByText("Es werden die neuesten 200 angezeigt.")).toBeInTheDocument();
});
it("offers Anzeige anlegen only with compliance:manage", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
const { unmount } = renderPage();
expect(await screen.findByRole("button", { name: "Anzeige anlegen" })).toBeInTheDocument();
unmount();
permissions = ["compliance:read"];
renderPage();
await screen.findByText(/Noch keine Anzeige/);
expect(screen.queryByRole("button", { name: "Anzeige anlegen" })).not.toBeInTheDocument();
});
it("says so without the read permission and loads nothing", () => {
permissions = [];
renderPage();
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchNotices).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { BookOpen } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { DetailHeader } from "@/components/detail-header";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status";
import { EmptyState } from "@/components/empty-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
import { NOTICE_KINDS, NOTICE_KIND_LABEL, noticeDay, noticeTitle } from "@/lib/compliance/notice-labels";
import type { AuthorityNotice, AuthorityNoticeKind } from "@/lib/compliance/notice-types";
import { fetchNotices } from "@/lib/compliance/notices-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { formatDay } from "@/lib/medicines/format";
import { CreateNoticeDialog } from "./create-notice-dialog";
type KindFilter = AuthorityNoticeKind | "ALL";
/** The server lists at most this many, newest first. */
const LIST_CAP = 200;
const YEAR_PATTERN = /^\d{4}$/;
export default function NoticesPage() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const canManage = hasPermission("compliance:manage");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [kind, setKind] = useState<KindFilter>("ALL");
const [yearText, setYearText] = useState("");
const year = YEAR_PATTERN.test(yearText.trim()) ? Number(yearText.trim()) : undefined;
const yearInvalid = yearText.trim() !== "" && year === undefined;
const query = useQuery({
queryKey: [...complianceKey, "notices", kind, year ?? "all"],
queryFn: () => fetchNotices({ ...(kind === "ALL" ? {} : { kind }), ...(year === undefined ? {} : { year }) }),
enabled: canRead,
});
if (!canRead) return <NoComplianceAccess />;
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
title="Anzeigen ans Veterinäramt"
subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung."
backHref="/compliance"
backLabel="Prüfbereitschaft"
primaryAction={canManage ? <CreateNoticeDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
/>
{canManage ? <OnlineOnlyHint id={hintId} /> : null}
<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="notice-kind-filter" className="text-sm font-medium">
Art
</label>
<select
id="notice-kind-filter"
value={kind}
onChange={(event) => setKind(event.target.value as KindFilter)}
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm"
>
<option value="ALL">Alle</option>
{NOTICE_KINDS.map((entry) => (
<option key={entry} value={entry}>
{NOTICE_KIND_LABEL[entry]}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="notice-year-filter" className="text-sm font-medium">
Jahr
</label>
<input
id="notice-year-filter"
inputMode="numeric"
maxLength={4}
value={yearText}
placeholder="alle"
aria-invalid={yearInvalid ? true : undefined}
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
onChange={(event) => setYearText(event.target.value)}
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm"
/>
{yearInvalid ? (
<p id="notice-year-problem" className="text-sm text-destructive">
Bitte vier Ziffern eingeben, zum Beispiel 2027.
</p>
) : null}
</div>
</div>
<div aria-live="polite">
<QueryState
query={query}
what="Die Anzeigen"
empty={
<EmptyState
icon={BookOpen}
title="Noch keine Anzeige"
description="Hier erscheinen deine Anzeigen ans Veterinäramt."
/>
}
>
{(notices) => (
<>
{notices.length >= LIST_CAP ? (
<p className="mb-2 text-sm text-muted-foreground">Es werden die neuesten {LIST_CAP} angezeigt.</p>
) : null}
<ul className="flex flex-col gap-2">
{notices.map((notice) => (
<li key={notice.id}>
<NoticeLink notice={notice} />
</li>
))}
</ul>
</>
)}
</QueryState>
</div>
</main>
);
}
function NoticeLink({ notice }: { notice: AuthorityNotice }) {
return (
<Link
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
<NoticeStatus status={notice.status} />
</span>
<span className="text-sm text-muted-foreground">
{notice.totalColonies} {notice.totalColonies === 1 ? "Volk" : "Völker"} · Stichtag {formatDay(noticeDay(notice.referenceDate))}
{notice.sentOn ? ` · gesendet am ${formatDay(noticeDay(notice.sentOn))}` : ""}
</span>
</Link>
);
}
@@ -168,12 +168,12 @@ describe("CompliancePage", () => {
it("falls back to showing the item when the page that fixes it does not exist yet", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([open({ key: "vet-office", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
reportFixture([open({ key: "unknown-rule", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
);
renderPage();
const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" });
expect(main).toHaveAttribute("href", "#item-vet-office-global");
expect(main).toHaveAttribute("href", "#item-unknown-rule-global");
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
});
});
@@ -82,8 +82,14 @@ describe("ReadinessItemCard", () => {
});
it("hides Beheben while the target page does not exist", () => {
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
renderCard(itemFixture({ key: "unknown-rule", area: "BEHOERDEN" }));
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
});
it("links the vet-office item to the settings page, which exists now", () => {
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute("href", "/compliance/settings");
});
});
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { createRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
import { CreateRestrictionDialog } from "./create-restriction-dialog";
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
createRestrictions: vi.fn(),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CreateRestrictionDialog />
</QueryClientProvider>,
);
}
async function openDialog() {
await userEvent.click(screen.getByRole("button", { name: "Sperrbezirk anlegen" }));
return screen.findByRole("dialog");
}
describe("CreateRestrictionDialog", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "a-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "a-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(createRestrictions).mockReset();
vi.mocked(createRestrictions).mockResolvedValue([]);
});
it("offers the disease as a ChoiceGroup with free text only for Andere", async () => {
renderDialog();
const dialog = await openDialog();
const group = within(dialog).getByRole("radiogroup", { name: /Seuche/ });
expect(within(group).getAllByRole("radio").map((radio) => radio.textContent)).toEqual([
"Amerikanische Faulbrut",
"Kleiner Beutenkäfer",
"Andere",
]);
expect(within(dialog).queryByLabelText(/Name der Seuche/)).not.toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Andere" }));
expect(within(dialog).getByLabelText(/Name der Seuche/)).toBeInTheDocument();
});
it("creates one restriction request for several apiaries", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("checkbox", { name: "Hausgarten" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Amerikanische Faulbrut" }));
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-10-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
await waitFor(() => expect(createRestrictions).toHaveBeenCalledTimes(1));
const [input, options] = vi.mocked(createRestrictions).mock.calls[0];
expect(input).toEqual({
apiaryIds: ["a-1", "a-2"],
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
});
expect(options?.idempotencyKey).toEqual(expect.any(String));
});
it("sends the free text of Andere as the disease", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Andere" }));
await userEvent.type(within(dialog).getByLabelText(/Name der Seuche/), "Tropilaelaps");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
await waitFor(() => expect(createRestrictions).toHaveBeenCalled());
expect(vi.mocked(createRestrictions).mock.calls[0][0]).toMatchObject({
apiaryIds: ["a-1"],
disease: "Tropilaelaps",
});
});
it("asks for apiary and disease at the fields instead of sending", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(within(dialog).getByText("Bitte mindestens einen Bienenstand auswählen.")).toBeInTheDocument();
expect(within(dialog).getByText("Bitte die Seuche auswählen.")).toBeInTheDocument();
expect(createRestrictions).not.toHaveBeenCalled();
});
it("refuses an end day before the start day", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Kleiner Beutenkäfer" }));
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-10-10");
await userEvent.type(within(dialog).getByLabelText(/Ende/), "2026-10-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(within(dialog).getByText("Das Ende darf nicht vor dem Beginn liegen.")).toBeInTheDocument();
expect(createRestrictions).not.toHaveBeenCalled();
});
it("shows a server error next to the button", async () => {
vi.mocked(createRestrictions).mockRejectedValue(new ApiError(404, "Bienenstand nicht gefunden"));
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Amerikanische Faulbrut" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Bienenstand nicht gefunden");
});
});
@@ -0,0 +1,312 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiaries } from "@/lib/apiaries/api";
import { complianceErrorText } from "@/lib/compliance/errors";
import {
createRestrictions,
type CreateRestrictionsInput,
} from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay } from "@/lib/dates";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
/** The server takes 1-50 apiaries per request, one restriction each. */
const MAX_APIARIES = 50;
const MAX_TEXT_LENGTH = 200;
const MAX_NOTES_LENGTH = 2000;
const OTHER = "OTHER";
type DiseaseChoice = "Amerikanische Faulbrut" | "Kleiner Beutenkäfer" | typeof OTHER | "";
const DISEASE_OPTIONS: readonly ChoiceOption<DiseaseChoice>[] = [
{ value: "Amerikanische Faulbrut", label: "Amerikanische Faulbrut" },
{ value: "Kleiner Beutenkäfer", label: "Kleiner Beutenkäfer" },
{ value: OTHER, label: "Andere" },
];
interface FormErrors {
apiaryIds?: string;
disease?: string;
otherDisease?: string;
startsOn?: string;
endsOn?: string;
}
interface FormValues {
apiaryIds: string[];
disease: DiseaseChoice;
otherDisease: string;
startsOn: string;
endsOn: string;
authority: string;
reference: string;
notes: string;
}
function emptyValues(): FormValues {
return {
apiaryIds: [],
disease: "",
otherDisease: "",
startsOn: berlinCalendarDay(new Date().toISOString()),
endsOn: "",
authority: "",
reference: "",
notes: "",
};
}
function validate(values: FormValues): FormErrors {
const errors: FormErrors = {};
if (values.apiaryIds.length === 0) errors.apiaryIds = "Bitte mindestens einen Bienenstand auswählen.";
if (values.apiaryIds.length > MAX_APIARIES) {
errors.apiaryIds = `Höchstens ${MAX_APIARIES} Bienenstände auf einmal.`;
}
if (values.disease === "") errors.disease = "Bitte die Seuche auswählen.";
if (values.disease === OTHER && !values.otherDisease.trim()) {
errors.otherDisease = "Bitte den Namen der Seuche angeben.";
}
if (!values.startsOn) errors.startsOn = "Bitte den Beginn angeben.";
if (values.endsOn && values.startsOn && values.endsOn < values.startsOn) {
errors.endsOn = "Das Ende darf nicht vor dem Beginn liegen.";
}
return errors;
}
function optional(text: string): string | undefined {
const trimmed = text.trim();
return trimmed === "" ? undefined : trimmed;
}
function toInput(values: FormValues): CreateRestrictionsInput {
const authority = optional(values.authority);
const reference = optional(values.reference);
const notes = optional(values.notes);
return {
apiaryIds: values.apiaryIds,
disease: values.disease === OTHER ? values.otherDisease.trim() : values.disease,
startsOn: values.startsOn,
...(values.endsOn ? { endsOn: values.endsOn } : {}),
...(authority ? { authority } : {}),
...(reference ? { reference } : {}),
...(notes ? { notes } : {}),
};
}
/** The main action of the restrictions page: one declaration for one or several apiaries. */
export function CreateRestrictionDialog() {
const queryClient = useQueryClient();
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [values, setValues] = useState<FormValues>(emptyValues);
const [errors, setErrors] = useState<FormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const apiaries = apiariesQuery.data ?? [];
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key]: undefined }));
}
function toggleApiary(apiaryId: string, checked: boolean) {
set(
"apiaryIds",
checked ? [...values.apiaryIds, apiaryId] : values.apiaryIds.filter((id) => id !== apiaryId),
);
}
const mutation = useMutation({
mutationFn: (input: CreateRestrictionsInput) =>
createRestrictions(input, { idempotencyKey: attempt.keyFor(input) }),
onSuccess: (rows) => {
attempt.done();
void queryClient.invalidateQueries({ queryKey: complianceKey });
toast.success(rows.length === 1 ? "Sperrbezirk eingetragen" : `${rows.length} Sperrbezirke eingetragen`);
setValues(emptyValues());
setOpen(false);
},
onError: (error) => {
attempt.failed(error);
setSubmitError(complianceErrorText(error, "Der Sperrbezirk konnte nicht eingetragen werden."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
mutation.mutate(toInput(values));
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="touch">Sperrbezirk anlegen</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Sperrbezirk anlegen</DialogTitle>
<DialogDescription>
Trag ein, welche Bienenstände das Veterinäramt zum Sperrbezirk erklärt hat. Für jeden Stand
entsteht ein eigener Eintrag.
</DialogDescription>
</DialogHeader>
<form className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto" onSubmit={handleSubmit} noValidate>
<Field
label="Betroffene Bienenstände"
group
required
error={errors.apiaryIds}
hint={`Alle antippen, die im Sperrbezirk liegen (höchstens ${MAX_APIARIES}).`}
>
{({ id, labelId, describedBy: fieldDescribedBy }) => (
<div
id={id}
role="group"
aria-labelledby={labelId}
aria-describedby={fieldDescribedBy}
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
{apiaries.map((apiary) => (
<ToggleChip
key={apiary.id}
label={apiary.name}
checked={values.apiaryIds.includes(apiary.id)}
onChange={(checked) => toggleApiary(apiary.id, checked)}
/>
))}
</div>
)}
</Field>
<Field label="Seuche" group required error={errors.disease}>
{({ id, labelId, describedBy: fieldDescribedBy }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
describedBy={fieldDescribedBy}
options={DISEASE_OPTIONS}
value={values.disease}
onChange={(value) => set("disease", value)}
className="grid-cols-1 sm:grid-cols-3"
/>
)}
</Field>
{values.disease === OTHER ? (
<Field label="Name der Seuche" required error={errors.otherDisease}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.otherDisease}
onChange={(event) => set("otherDisease", event.target.value)}
/>
)}
</Field>
) : null}
<Field label="Beginn" required error={errors.startsOn} hint="Erster Tag der Sperre.">
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="date"
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.startsOn}
onChange={(event) => set("startsOn", event.target.value)}
/>
)}
</Field>
<Field label="Ende" error={errors.endsOn} hint="Letzter Tag der Sperre. Leer = bis auf Weiteres.">
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="date"
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.endsOn}
onChange={(event) => set("endsOn", event.target.value)}
/>
)}
</Field>
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={3}>
<Field label="Behörde">
{({ id }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
value={values.authority}
onChange={(event) => set("authority", event.target.value)}
/>
)}
</Field>
<Field label="Aktenzeichen">
{({ id }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
value={values.reference}
onChange={(event) => set("reference", event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea
id={id}
maxLength={MAX_NOTES_LENGTH}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</FormSection>
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{submitError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={isOffline || mutation.isPending}
aria-describedby={describedBy}
>
{mutation.isPending ? "Wird eingetragen…" : "Sperrbezirk eintragen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { formatDay } from "@/lib/medicines/format";
import { complianceErrorText } from "@/lib/compliance/errors";
import { endRestriction, type ApiaryRestriction } from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay, toCalendarDay } from "@/lib/dates";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Field } from "@/components/form/field";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Yesterday (Berlin day), but not before the start: the end day is the last
* day the restriction still holds, so "lifted today" means "until yesterday".
*/
function defaultEndDay(startsOn: string): string {
const today = berlinCalendarDay(new Date().toISOString());
const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - MS_PER_DAY).toISOString().slice(0, 10);
return yesterday < startsOn ? startsOn : yesterday;
}
/** The restriction still holds on the end day (inclusive), so today or later is not "lifted". */
function stillHoldsText(endsOn: string): string {
return `Die Sperre gilt noch bis einschließlich ${formatDay(endsOn)}.`;
}
/** Ending sets the last day; the entry stays in the list as "Beendet". */
export function EndRestrictionDialog({
restriction,
apiaryName,
}: {
restriction: ApiaryRestriction;
apiaryName: string;
}) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const startsOn = toCalendarDay(restriction.startsOn);
const [open, setOpen] = useState(false);
const [endsOn, setEndsOn] = useState(() => defaultEndDay(startsOn));
const [error, setError] = useState<string | null>(null);
const today = berlinCalendarDay(new Date().toISOString());
const stillHolds = endsOn !== "" && endsOn >= today;
const mutation = useMutation({
mutationFn: () => endRestriction(restriction.id, endsOn),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: complianceKey });
toast.success(stillHolds ? stillHoldsText(endsOn) : "Sperrbezirk beendet");
setOpen(false);
},
onError: (mutationError) =>
setError(complianceErrorText(mutationError, "Der Sperrbezirk konnte nicht beendet werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!endsOn) {
setError("Bitte den letzten Tag angeben.");
return;
}
if (endsOn < startsOn) {
setError("Das Ende darf nicht vor dem Beginn liegen.");
return;
}
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="touch" aria-label={`Beenden: ${apiaryName}`}>
Beenden
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Sperrbezirk beenden</DialogTitle>
<DialogDescription>
{`${apiaryName}: Der Eintrag bleibt erhalten und steht danach unter „Beendet“.`}
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field
label="Letzter Tag der Sperre"
required
hint={
stillHolds
? stillHoldsText(endsOn)
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein."
}
>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
type="date"
className="h-11"
min={startsOn}
aria-describedby={fieldDescribedBy}
value={endsOn}
onChange={(event) => setEndsOn(event.target.value)}
/>
)}
</Field>
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={isOffline || mutation.isPending}
aria-describedby={describedBy}
>
{mutation.isPending ? "Wird beendet…" : "Sperrbezirk beenden"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,193 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import {
endRestriction,
fetchRestrictions,
type ApiaryRestriction,
} from "@/lib/compliance/restrictions-api";
import RestrictionsPage from "./page";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchRestrictions: vi.fn(),
createRestrictions: vi.fn(),
endRestriction: vi.fn(),
}));
vi.mock("@/components/document-upload-card", () => ({
DocumentUploadCard: ({
entityType,
entityId,
canWrite,
}: {
entityType: string;
entityId: string;
canWrite?: boolean;
}) => (
<p>
Dokumente {entityType} {entityId} {canWrite ? "schreibend" : "lesend"}
</p>
),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
return {
id: "r-1",
apiaryId: "a-1",
disease: "Amerikanische Faulbrut",
authority: "Veterinäramt Musterstadt",
reference: "AZ 12/34",
startsOn: "2020-10-01T00:00:00.000Z",
endsOn: null,
notes: null,
...overrides,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<RestrictionsPage />
</QueryClientProvider>,
);
}
describe("RestrictionsPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "a-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "a-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(fetchRestrictions).mockResolvedValue([
row({ id: "r-1" }),
row({ id: "r-2", apiaryId: "a-2", endsOn: "2020-11-01T00:00:00.000Z" }),
]);
vi.mocked(fetchRestrictions).mockClear();
vi.mocked(endRestriction).mockReset();
vi.mocked(endRestriction).mockResolvedValue(row({}));
});
it("lists active and ended restrictions apart, each with word and symbol", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
const ended = screen.getByRole("region", { name: /Beendet/ });
expect(within(active).getByText("Obstwiese")).toBeInTheDocument();
expect(within(active).getByText(/seit 01\.10\.2020/)).toBeInTheDocument();
expect(within(active).getByText(/bis auf Weiteres/)).toBeInTheDocument();
expect(within(active).getByText("Aktiv", { selector: "[data-slot=badge]" })).toBeInTheDocument();
expect(within(ended).getByText("Hausgarten")).toBeInTheDocument();
expect(within(ended).getByText(/bis 01\.11\.2020/)).toBeInTheDocument();
expect(within(ended).getByText("Beendet", { selector: "[data-slot=badge]" })).toBeInTheDocument();
expect(within(ended).queryByRole("button", { name: /Beenden/ })).not.toBeInTheDocument();
});
it("ends a restriction by setting endsOn and keeps the entry", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
await userEvent.clear(within(dialog).getByLabelText(/Letzter Tag/));
await userEvent.type(within(dialog).getByLabelText(/Letzter Tag/), "2026-11-30");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
await waitFor(() => expect(endRestriction).toHaveBeenCalledWith("r-1", "2026-11-30"));
expect(screen.getByText("Obstwiese")).toBeInTheDocument();
});
it("proposes yesterday as the last day, but never before the start", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
const field = within(dialog).getByLabelText(/Letzter Tag/) as HTMLInputElement;
const yesterday = new Date(Date.now() - 36 * 60 * 60 * 1000).toISOString().slice(0, 10);
expect(field.value >= "2020-10-01").toBe(true);
expect(field.value <= new Date().toISOString().slice(0, 10)).toBe(true);
expect(field.value >= yesterday).toBe(true);
});
it("opens the Verfügung upload for APIARY_RESTRICTION", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Verfügung/ }));
expect(screen.getByText("Dokumente APIARY_RESTRICTION r-1 schreibend")).toBeInTheDocument();
});
it("is read only without compliance:manage", async () => {
permissions = ["compliance:read"];
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
expect(screen.queryByRole("button", { name: "Sperrbezirk anlegen" })).not.toBeInTheDocument();
expect(within(active).queryByRole("button", { name: /Beenden/ })).not.toBeInTheDocument();
await userEvent.click(within(active).getByRole("button", { name: /Verfügung/ }));
expect(screen.getByText("Dokumente APIARY_RESTRICTION r-1 lesend")).toBeInTheDocument();
});
it("offers the creation as the one main action with compliance:manage", async () => {
renderPage();
expect(await screen.findByRole("button", { name: "Sperrbezirk anlegen" })).toBeInTheDocument();
});
it("shows a future restriction as Geplant with clock and word, not under Beendet", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([
row({ id: "r-3", startsOn: "2999-01-01T00:00:00.000Z" }),
]);
renderPage();
const planned = await screen.findByRole("region", { name: /Geplant/ });
expect(within(planned).getByText("Geplant", { selector: "[data-slot=badge]" }).querySelector("svg")).not.toBeNull();
expect(screen.queryByRole("region", { name: /Beendet/ })).not.toBeInTheDocument();
expect(screen.getByText("Aktuell ist kein Stand im Sperrbezirk.")).toBeInTheDocument();
});
it("says the restriction still holds when the end day is today or later", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Letzter Tag/), { target: { value: "2999-02-03" } });
expect(within(dialog).getByText("Die Sperre gilt noch bis einschließlich 03.02.2999.")).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Die Sperre gilt noch bis einschließlich 03.02.2999."));
});
it("keeps the live region for loading and error only", async () => {
const { container } = renderPage();
await screen.findByRole("region", { name: /Aktiv/ });
const live = container.querySelector("[aria-live=polite]");
expect(live).not.toBeNull();
expect(live?.querySelector("section")).toBeNull();
});
it("shows the missing-right text instead of the page without compliance:read", async () => {
permissions = [];
renderPage();
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchRestrictions).not.toHaveBeenCalled();
});
it("says so when there are no restrictions", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([]);
renderPage();
expect(await screen.findByText("Aktuell ist kein Stand im Sperrbezirk.")).toBeInTheDocument();
});
});
@@ -0,0 +1,153 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { OctagonAlert } from "lucide-react";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader } from "@/components/detail-header";
import { StandNote } from "@/components/offline/stand-note";
import { ErrorState, LoadingState } from "@/components/query-state";
import { fetchApiaries } from "@/lib/apiaries/api";
import { useAuth } from "@/lib/auth/auth-context";
import {
fetchRestrictions,
restrictionState,
type ApiaryRestriction,
type RestrictionState,
} from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay } from "@/lib/dates";
import { CreateRestrictionDialog } from "./create-restriction-dialog";
import { RestrictionRow } from "./restriction-row";
const restrictionListKey = [...complianceKey, "restrictions", "list"] as const;
const UNKNOWN_APIARY = "Unbekannter Stand";
function Section({
id,
title,
rows,
emptyText,
nameOf,
state,
canManage,
canRead,
}: {
id: string;
title: string;
rows: ApiaryRestriction[];
emptyText: string;
nameOf: (apiaryId: string) => string;
state: RestrictionState;
canManage: boolean;
canRead: boolean;
}) {
const headingId = `restrictions-${id}`;
return (
<section aria-labelledby={headingId} className="flex flex-col gap-2">
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
{`${title} (${rows.length})`}
</h2>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyText}</p>
) : (
<ul className="flex flex-col gap-2">
{rows.map((row) => (
<RestrictionRow
key={row.id}
restriction={row}
apiaryName={nameOf(row.apiaryId)}
state={state}
canManage={canManage}
canRead={canRead}
/>
))}
</ul>
)}
</section>
);
}
export default function RestrictionsPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission("compliance:manage");
const canRead = hasPermission("compliance:read");
const query = useQuery({ queryKey: restrictionListKey, queryFn: fetchRestrictions, enabled: canRead });
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries(), enabled: canRead });
const data = query.data;
const names = useMemo(
() => new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])),
[apiariesQuery.data],
);
const { active, planned, ended } = useMemo(() => {
// The Berlin calendar day, as on the server.
const today = berlinCalendarDay(new Date().toISOString());
const rows = data ?? [];
const by = (state: RestrictionState) => rows.filter((row) => restrictionState(row, today) === state);
return { active: by("active"), planned: by("planned"), ended: by("ended") };
}, [data]);
const nameOf = (apiaryId: string) => names.get(apiaryId) ?? UNKNOWN_APIARY;
if (!canRead) return <NoComplianceAccess />;
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
<DetailHeader
icon={OctagonAlert}
title="Sperrbezirke"
subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat."
backHref="/compliance"
backLabel="Prüfbereitschaft"
primaryAction={canManage ? <CreateRestrictionDialog /> : undefined}
/>
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError && data !== undefined} />
<div aria-live="polite">
{query.isLoading ? <LoadingState what="Die Sperrbezirke" /> : null}
{!query.isLoading && data === undefined ? (
<ErrorState what="Die Sperrbezirke" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
</div>
<div className="flex flex-col gap-6">
{data ? (
<>
<Section
id="active"
title="Aktiv"
rows={active}
emptyText="Aktuell ist kein Stand im Sperrbezirk."
nameOf={nameOf}
state="active"
canManage={canManage}
canRead={canRead}
/>
{planned.length > 0 ? (
<Section
id="planned"
title="Geplant"
rows={planned}
emptyText=""
nameOf={nameOf}
state="planned"
canManage={canManage}
canRead={canRead}
/>
) : null}
{ended.length > 0 ? (
<Section
id="ended"
title="Beendet"
rows={ended}
emptyText=""
nameOf={nameOf}
state="ended"
canManage={canManage}
canRead={canRead}
/>
) : null}
</>
) : null}
</div>
</main>
);
}
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CheckCircle2, Clock, FileText, OctagonAlert } from "lucide-react";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ApiaryRestriction, RestrictionState } from "@/lib/compliance/restrictions-api";
import { toCalendarDay } from "@/lib/dates";
import { formatDay } from "@/lib/medicines/format";
import { EndRestrictionDialog } from "./end-restriction-dialog";
interface RestrictionRowProps {
restriction: ApiaryRestriction;
apiaryName: string;
state: RestrictionState;
canManage: boolean;
canRead: boolean;
}
function periodOf(restriction: ApiaryRestriction): string {
const since = `seit ${formatDay(toCalendarDay(restriction.startsOn))}`;
return restriction.endsOn === null
? `${since} · bis auf Weiteres`
: `${since} · bis ${formatDay(toCalendarDay(restriction.endsOn))}`;
}
/** One restriction: state as word and symbol, the Verfügung behind a button. */
export function RestrictionRow({ restriction, apiaryName, state, canManage, canRead }: RestrictionRowProps) {
const [showDocuments, setShowDocuments] = useState(false);
const details = [restriction.authority, restriction.reference].filter(Boolean).join(" · ");
return (
<li
data-restriction-id={restriction.id}
className="flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-heading text-base font-semibold">
<Link href={`/apiaries/${encodeURIComponent(restriction.apiaryId)}`} className="underline-offset-4 hover:underline">
{apiaryName}
</Link>
</h3>
{state === "active" ? (
<Badge variant="destructive" className="h-6 gap-1.5 px-2.5 text-sm">
<OctagonAlert aria-hidden />
Aktiv
</Badge>
) : state === "planned" ? (
<Badge variant="outline" className="h-6 gap-1.5 px-2.5 text-sm">
<Clock aria-hidden />
Geplant
</Badge>
) : (
<Badge variant="secondary" className="h-6 gap-1.5 px-2.5 text-sm">
<CheckCircle2 aria-hidden />
Beendet
</Badge>
)}
</div>
<p className="text-sm font-medium">{restriction.disease}</p>
<p className="text-sm text-muted-foreground">{periodOf(restriction)}</p>
{details ? <p className="text-sm text-muted-foreground">{details}</p> : null}
{restriction.notes ? <p className="text-sm text-muted-foreground">{restriction.notes}</p> : null}
<div className="flex flex-wrap gap-2">
{canRead ? (
<Button
type="button"
variant="outline"
size="touch"
aria-expanded={showDocuments}
onClick={() => setShowDocuments((current) => !current)}
>
<FileText aria-hidden />
Verfügung
</Button>
) : null}
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} /> : null}
</div>
{canRead && showDocuments ? (
<DocumentUploadCard
entityType="APIARY_RESTRICTION"
entityId={restriction.id}
title="Verfügung"
canWrite={canManage}
/>
) : null}
</li>
);
}
@@ -0,0 +1,164 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { parseDecimal } from "@/lib/medicines/format";
import type { NetQuantityDigitRule } from "@/lib/compliance/notice-types";
/** A rule as typed: German decimals stay text until the form is saved. */
export interface DigitRuleText {
maxGrams: string;
minMm: string;
}
export const DIGIT_RULES_PROBLEM = "Bitte die Ziffernhöhen unter „Erweitert“ prüfen.";
/** Bounds of the backend (compliance-settings.service.ts): 1 to 10 rules, 0.5 to 20 mm. */
export const MIN_DIGIT_RULES = 1;
export const MAX_DIGIT_RULES = 10;
const MIN_DIGIT_MM = 0.5;
const MAX_DIGIT_MM = 20;
export const HEIGHT_PROBLEM = "Die Ziffernhöhe muss zwischen 0,5 und 20 mm liegen.";
export const LIMIT_PROBLEM = "Die Obergrenzen müssen größer als 0 sein und aufsteigen.";
function numberText(value: number | null): string {
return value === null ? "" : String(value).replace(".", ",");
}
export function digitRulesToText(rules: readonly NetQuantityDigitRule[]): DigitRuleText[] {
return rules.map((rule) => ({ maxGrams: numberText(rule.maxGrams), minMm: numberText(rule.minMm) }));
}
/**
* Per-row messages, mirroring the backend's checks: the height range, limits
* that are above 0 and ascend, and a last row that has no limit (the editor
* always shows the last row without one). An empty result means valid.
*/
export function digitRuleProblems(rows: readonly DigitRuleText[]): Record<number, string> {
const problems: Record<number, string> = {};
let previous = 0;
rows.forEach((row, index) => {
const minMm = parseDecimal(row.minMm);
const heightOk = Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM;
let limitOk = true;
if (index < rows.length - 1) {
const maxGrams = parseDecimal(row.maxGrams);
limitOk = Number.isFinite(maxGrams) && maxGrams > previous;
if (limitOk) previous = maxGrams;
}
if (!heightOk) problems[index] = HEIGHT_PROBLEM;
else if (!limitOk) problems[index] = LIMIT_PROBLEM;
});
return problems;
}
/** The typed rules as numbers; call only when `digitRuleProblems` is empty. The last row has no limit. */
export function digitRulesFromText(rows: readonly DigitRuleText[]): NetQuantityDigitRule[] {
return rows.map((row, index) => ({
maxGrams: index === rows.length - 1 ? null : parseDecimal(row.maxGrams),
minMm: parseDecimal(row.minMm),
}));
}
interface DigitRulesFieldProps {
rows: DigitRuleText[];
readOnly: boolean;
noteId: string;
problems: Record<number, string>;
onChange: (rows: DigitRuleText[]) => void;
}
/** Minimum digit height of the net quantity per jar size; the values are settings, not code. */
export function DigitRulesField({ rows, readOnly, noteId, problems, onChange }: DigitRulesFieldProps) {
function setRow(index: number, patch: Partial<DigitRuleText>) {
onChange(rows.map((row, position) => (position === index ? { ...row, ...patch } : row)));
}
// A new rule goes before the last one, which stays the one without a limit.
function addRow() {
onChange([...rows.slice(0, -1), { maxGrams: "", minMm: "" }, rows[rows.length - 1]]);
}
function removeRow(index: number) {
onChange(rows.filter((_, position) => position !== index));
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Mindesthöhe der Ziffern</p>
<p id={noteId} className="text-xs text-muted-foreground">
Annahme – bitte prüfen
</p>
<ul className="flex flex-col gap-3" aria-describedby={noteId}>
{rows.map((row, index) => {
const isLast = index === rows.length - 1;
const previous = index > 0 ? rows[index - 1].maxGrams : "";
return (
<li key={index} className="grid grid-cols-2 gap-3 rounded-lg bg-muted/50 p-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-max-${index}`}>
{isLast ? `Über ${previous || "…"} g` : "Bis (g)"}
</Label>
<Input
id={`digit-max-${index}`}
inputMode="decimal"
value={isLast ? "" : row.maxGrams}
placeholder={isLast ? "alles darüber" : undefined}
readOnly={readOnly || isLast}
aria-invalid={problems[index] ? true : undefined}
aria-describedby={problems[index] ? `digit-problem-${index}` : undefined}
onChange={(event) => setRow(index, { maxGrams: event.target.value })}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-min-${index}`}>Mindestens (mm)</Label>
<Input
id={`digit-min-${index}`}
inputMode="decimal"
value={row.minMm}
readOnly={readOnly}
onChange={(event) => setRow(index, { minMm: event.target.value })}
/>
</div>
{problems[index] ? (
<p id={`digit-problem-${index}`} role="alert" className="col-span-2 text-sm text-destructive">
{problems[index]}
</p>
) : null}
{!readOnly ? (
<Button
type="button"
variant="outline"
size="touch"
className="col-span-2 justify-self-start"
disabled={rows.length <= MIN_DIGIT_RULES}
aria-label={`Entfernen, Regel ${index + 1}`}
onClick={() => removeRow(index)}
>
<Trash2 aria-hidden />
Entfernen
</Button>
) : null}
</li>
);
})}
</ul>
{!readOnly ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={rows.length >= MAX_DIGIT_RULES}
onClick={addRow}
>
<Plus aria-hidden />
Regel hinzufügen
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ComplianceSettingsPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { settingsFixture } from "@/lib/compliance/notice-fixtures";
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
const ASSUMPTION = "Annahme – bitte prüfen";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/settings-api", () => ({
fetchComplianceSettings: vi.fn(),
updateComplianceSettings: vi.fn(),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return {
client,
...render(
<QueryClientProvider client={client}>
<ComplianceSettingsPage />
</QueryClientProvider>,
),
};
}
describe("ComplianceSettingsPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchComplianceSettings).mockReset();
vi.mocked(updateComplianceSettings).mockReset();
vi.mocked(fetchComplianceSettings).mockResolvedValue(settingsFixture());
});
it('shows the note "Annahme – bitte prüfen" at the trigger switches, the reference day and the due day', async () => {
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
expect(referenceDay).toHaveAccessibleDescription(new RegExp(ASSUMPTION));
expect(screen.getByLabelText("Meldefrist (MM-TT)")).toHaveAccessibleDescription(new RegExp(ASSUMPTION));
expect(screen.getByRole("checkbox", { name: "Bei geänderter Völkerzahl" })).toHaveAccessibleDescription(
new RegExp(ASSUMPTION),
);
expect(screen.getByRole("checkbox", { name: "Bei geändertem Stand" })).toHaveAccessibleDescription(
new RegExp(ASSUMPTION),
);
// A visible note, not only a tooltip.
expect(screen.getAllByText(ASSUMPTION).length).toBeGreaterThanOrEqual(4);
});
it("keeps counted colony types and digit heights collapsed under Erweitert", async () => {
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
expect(screen.queryByText("Gezählte Völkerarten")).not.toBeInTheDocument();
expect(screen.queryByText("Mindesthöhe der Ziffern")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
expect(screen.getByText("Gezählte Völkerarten")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Wirtschaftsvolk" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("Mindesthöhe der Ziffern")).toBeInTheDocument();
});
it("saves the counted types as the excluded ones", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
await userEvent.click(screen.getByRole("checkbox", { name: "Ableger" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(
expect.objectContaining({ tskExcludedColonyTypes: ["NUCLEUS_COLONY"], tskReportDueDay: null }),
);
});
it.each(["02-29", "04-31", "13-01", "1-1"])("an impossible reference day %s is stopped at the field", async (day) => {
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.clear(referenceDay);
await userEvent.type(referenceDay, day);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Bitte einen Tag im Format MM-TT eingeben");
expect(referenceDay).toHaveAccessibleDescription(/Bitte einen Tag im Format MM-TT/);
expect(updateComplianceSettings).not.toHaveBeenCalled();
});
it("checks the due day the same way but lets it stay empty", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
renderPage();
const due = await screen.findByLabelText("Meldefrist (MM-TT)");
await userEvent.type(due, "02-30");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("MM-TT");
expect(updateComplianceSettings).not.toHaveBeenCalled();
await userEvent.clear(due);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(expect.objectContaining({ tskReportDueDay: null }));
});
it("shows the backend's array message in German when the server still rejects", async () => {
const message = ["tskMemberNumber ist zu lang"];
vi.mocked(updateComplianceSettings).mockRejectedValue(new ApiError(400, "x", { message }));
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("tskMemberNumber ist zu lang");
});
it("shows the server message of a plain 400", async () => {
const text = "Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine";
vi.mocked(updateComplianceSettings).mockRejectedValue(new ApiError(400, text, { message: text }));
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
describe("digit rules", () => {
async function openRules() {
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
}
it("adds a rule before the last one and removes one, between 1 and 10", async () => {
await openRules();
expect(screen.getAllByRole("button", { name: /^Entfernen/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
expect(screen.getAllByRole("button", { name: /^Entfernen/ })).toHaveLength(3);
// The last row still has no limit.
expect(document.getElementById("digit-max-2")).toHaveAttribute("readonly");
await userEvent.click(screen.getByRole("button", { name: "Entfernen, Regel 1" }));
await userEvent.click(screen.getByRole("button", { name: "Entfernen, Regel 1" }));
expect(screen.getByRole("button", { name: "Entfernen, Regel 1" })).toBeDisabled();
});
it("stops at ten rules", async () => {
await openRules();
for (let count = 2; count < 10; count += 1) {
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
}
expect(screen.getByRole("button", { name: "Regel hinzufügen" })).toBeDisabled();
});
it("shows a message at the row for a height outside the backend range and for limits that do not ascend", async () => {
await openRules();
const first = document.getElementById("digit-min-0") as HTMLElement;
await userEvent.clear(first);
await userEvent.type(first, "25");
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
await userEvent.type(document.getElementById("digit-min-1") as HTMLElement, "1");
await userEvent.type(document.getElementById("digit-max-1") as HTMLElement, "50");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const row = (index: number) => document.getElementById(`digit-min-${index}`)?.closest("li") as HTMLElement;
expect(within(row(0)).getByRole("alert")).toHaveTextContent("zwischen 0,5 und 20 mm");
expect(within(row(1)).getByRole("alert")).toHaveTextContent("Obergrenzen");
expect(updateComplianceSettings).not.toHaveBeenCalled();
});
it("sends valid rules with the last one unlimited", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
await openRules();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(
expect.objectContaining({
netQuantityDigitRules: [
{ maxGrams: 100, minMm: 1.5 },
{ maxGrams: null, minMm: 2.5 },
],
}),
);
});
});
it("keeps unsaved input on a background refetch and offers to reload", async () => {
const { client } = renderPage();
const name = await screen.findByLabelText("Name des Veterinäramts");
await userEvent.clear(name);
await userEvent.type(name, "Mein Amt");
act(() => {
client.setQueryData(["compliance", "settings"], settingsFixture({ registrationNumber: "R-1" }));
});
expect(await screen.findByText("Daten wurden geändert – neu laden?")).toBeInTheDocument();
expect(screen.getByLabelText("Name des Veterinäramts")).toHaveValue("Mein Amt");
await userEvent.click(screen.getByRole("button", { name: "Neu laden" }));
expect(screen.getByLabelText("Name des Veterinäramts")).toHaveValue("Veterinäramt Musterstadt");
expect(screen.getByLabelText("Registriernummer")).toHaveValue("R-1");
});
it("takes over new server data silently while nothing was typed", async () => {
const { client } = renderPage();
await screen.findByLabelText("Name des Veterinäramts");
act(() => {
client.setQueryData(["compliance", "settings"], settingsFixture({ registrationNumber: "R-2" }));
});
expect(await screen.findByDisplayValue("R-2")).toBeInTheDocument();
expect(screen.queryByText("Daten wurden geändert – neu laden?")).not.toBeInTheDocument();
});
it("STAFF sees the settings read-only without a save button", async () => {
permissions = ["compliance:read"];
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
expect(referenceDay).toHaveAttribute("readonly");
expect(screen.getByRole("checkbox", { name: "Bei geänderter Völkerzahl" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
});
it("says so without the read permission and loads nothing", () => {
permissions = [];
renderPage();
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchComplianceSettings).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,367 @@
"use client";
import { FormEvent, useState } from "react";
import { Settings } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth/auth-context";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import type { ColonyType } from "@/lib/colonies/types";
import { describeSaveError } from "@/lib/honey-batches/errors";
import { complianceErrorText } from "@/lib/compliance/errors";
import { MONTH_DAY_PROBLEM, isValidMonthDay } from "@/lib/compliance/month-day";
import type { ComplianceSettings } from "@/lib/compliance/notice-types";
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import {
DIGIT_RULES_PROBLEM,
DigitRulesField,
type DigitRuleText,
digitRuleProblems,
digitRulesFromText,
digitRulesToText,
} from "./digit-rules-field";
const ASSUMPTION = "Annahme – bitte prüfen";
interface FormValues {
veterinaryOfficeName: string;
veterinaryOfficeAddress: string;
registrationNumber: string;
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
tskReferenceDay: string;
tskReportDueDay: string;
tskMemberNumber: string;
/** The types that are counted; the server stores the excluded ones. */
counted: ColonyType[];
rules: DigitRuleText[];
}
function valuesOf(settings: ComplianceSettings): FormValues {
return {
veterinaryOfficeName: settings.veterinaryOfficeName ?? "",
veterinaryOfficeAddress: settings.veterinaryOfficeAddress ?? "",
registrationNumber: settings.registrationNumber ?? "",
noticeOnColonyCountChange: settings.noticeOnColonyCountChange,
noticeOnApiaryChange: settings.noticeOnApiaryChange,
tskReferenceDay: settings.tskReferenceDay,
tskReportDueDay: settings.tskReportDueDay ?? "",
tskMemberNumber: settings.tskMemberNumber ?? "",
counted: COLONY_TYPES.filter((type) => !settings.tskExcludedColonyTypes.includes(type)),
rules: digitRulesToText(settings.netQuantityDigitRules),
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
/** The request body; call only when the form passed `problemsOf`. */
function bodyOf(values: FormValues): Partial<ComplianceSettings> {
const rules = digitRulesFromText(values.rules);
return {
veterinaryOfficeName: textOrNull(values.veterinaryOfficeName),
veterinaryOfficeAddress: textOrNull(values.veterinaryOfficeAddress),
registrationNumber: textOrNull(values.registrationNumber),
noticeOnColonyCountChange: values.noticeOnColonyCountChange,
noticeOnApiaryChange: values.noticeOnApiaryChange,
tskReferenceDay: values.tskReferenceDay.trim(),
tskReportDueDay: textOrNull(values.tskReportDueDay),
tskMemberNumber: textOrNull(values.tskMemberNumber),
tskExcludedColonyTypes: COLONY_TYPES.filter((type) => !values.counted.includes(type)),
netQuantityDigitRules: rules,
};
}
/** Client-side checks that mirror the backend, so a wrong day is caught at the field. */
function problemsOf(values: FormValues): {
fields: Record<string, string>;
rules: Record<number, string>;
} {
const fields: Record<string, string> = {};
if (!isValidMonthDay(values.tskReferenceDay.trim())) fields.tskReferenceDay = MONTH_DAY_PROBLEM;
const due = values.tskReportDueDay.trim();
if (due !== "" && !isValidMonthDay(due)) fields.tskReportDueDay = MONTH_DAY_PROBLEM;
return { fields, rules: digitRuleProblems(values.rules) };
}
/** The visible note at a field whose value is an assumption the owner must check. */
function AssumptionNote({ id }: { id: string }) {
return (
<p id={id} className="text-xs text-muted-foreground">
{ASSUMPTION}
</p>
);
}
export default function ComplianceSettingsPage() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const query = useQuery({
queryKey: [...complianceKey, "settings"],
queryFn: fetchComplianceSettings,
enabled: canRead,
});
if (!canRead) return <NoComplianceAccess />;
if (query.isLoading || query.isError || !query.data) {
return <DetailQueryFallback query={query} what="Die Einstellungen" backHref="/compliance" backLabel="Prüfbereitschaft" />;
}
return <SettingsForm settings={query.data} canManage={hasPermission("compliance:manage")} />;
}
function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; canManage: boolean }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [loaded, setLoaded] = useState(settings);
const [values, setValues] = useState<FormValues>(() => valuesOf(settings));
const [ruleProblems, setRuleProblems] = useState<Record<number, string>>({});
const [general, setGeneral] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [saved, setSaved] = useState("");
const readOnly = !canManage;
// A refetch must not wipe what is being typed: with unsaved input the person
// is asked, without it the new data is taken over.
const serverChanged = JSON.stringify(settings) !== JSON.stringify(loaded);
const isDirty = JSON.stringify(values) !== JSON.stringify(valuesOf(loaded));
if (serverChanged && !isDirty) {
setLoaded(settings);
setValues(valuesOf(settings));
}
function reload() {
setLoaded(settings);
setValues(valuesOf(settings));
setRuleProblems({});
setFieldErrors({});
setGeneral(null);
}
const mutation = useMutation({
mutationFn: (body: Partial<ComplianceSettings>) => updateComplianceSettings(body),
onSuccess: (next) => {
setLoaded(next);
setValues(valuesOf(next));
queryClient.setQueryData([...complianceKey, "settings"], next);
void queryClient.invalidateQueries({ queryKey: complianceKey });
setSaved("Gespeichert.");
},
onError: (error) => {
const fallback = "Die Einstellungen wurden nicht gespeichert.";
const { fields } = describeSaveError(error, fallback);
setGeneral(Object.keys(fields).length > 0 ? null : complianceErrorText(error, fallback));
setFieldErrors(fields);
},
});
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
function toggleCounted(type: ColonyType, checked: boolean) {
set(
"counted",
COLONY_TYPES.filter((entry) => (entry === type ? checked : values.counted.includes(entry))),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setGeneral(null);
setFieldErrors({});
setSaved("");
const problems = problemsOf(values);
setRuleProblems(problems.rules);
setFieldErrors(problems.fields);
if (Object.keys(problems.rules).length > 0) setGeneral(DIGIT_RULES_PROBLEM);
if (Object.keys(problems.fields).length > 0 || Object.keys(problems.rules).length > 0) return;
if (isOffline) return;
mutation.mutate(bodyOf(values));
}
const fieldError = (name: string) => fieldErrors[name];
const describedByFor = (...ids: string[]) => ids.join(" ");
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Settings}
title="Einstellungen Behörden"
subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."}
backHref="/compliance"
backLabel="Prüfbereitschaft"
/>
{serverChanged && isDirty ? (
<div role="status" className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-muted/50 p-3 text-sm">
<span>Daten wurden geändert – neu laden?</span>
<Button type="button" variant="outline" size="touch" onClick={reload}>
Neu laden
</Button>
</div>
) : null}
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection title="Veterinäramt" step={1}>
<div className="flex flex-col gap-2">
<Label htmlFor="vet-name">Name des Veterinäramts</Label>
<Input
id="vet-name"
value={values.veterinaryOfficeName}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("veterinaryOfficeName", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vet-address">Anschrift des Veterinäramts</Label>
<Textarea
id="vet-address"
value={values.veterinaryOfficeAddress}
maxLength={300}
readOnly={readOnly}
onChange={(event) => set("veterinaryOfficeAddress", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="registration-number">Registriernummer</Label>
<Input
id="registration-number"
value={values.registrationNumber}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("registrationNumber", event.target.value)}
/>
</div>
</FormSection>
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
<div className="flex flex-col gap-1">
<ToggleChip
label="Bei geänderter Völkerzahl"
checked={values.noticeOnColonyCountChange}
disabled={readOnly}
describedBy="note-trigger-count"
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
/>
<AssumptionNote id="note-trigger-count" />
</div>
<div className="flex flex-col gap-1">
<ToggleChip
label="Bei geändertem Stand"
checked={values.noticeOnApiaryChange}
disabled={readOnly}
describedBy="note-trigger-apiary"
onChange={(checked) => set("noticeOnApiaryChange", checked)}
/>
<AssumptionNote id="note-trigger-apiary" />
</div>
</FormSection>
<FormSection title="Tierseuchenkasse (TSK)" step={3}>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-reference-day">Stichtag (MM-TT)</Label>
<Input
id="tsk-reference-day"
value={values.tskReferenceDay}
placeholder="01-01"
maxLength={5}
readOnly={readOnly}
aria-invalid={fieldError("tskReferenceDay") ? true : undefined}
aria-describedby={describedByFor("note-reference-day", ...(fieldError("tskReferenceDay") ? ["error-reference-day"] : []))}
onChange={(event) => set("tskReferenceDay", event.target.value)}
/>
<AssumptionNote id="note-reference-day" />
{fieldError("tskReferenceDay") ? (
<p id="error-reference-day" role="alert" className="text-sm text-destructive">
{fieldError("tskReferenceDay")}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-due-day">Meldefrist (MM-TT)</Label>
<Input
id="tsk-due-day"
value={values.tskReportDueDay}
placeholder="leer = keine Frist"
maxLength={5}
readOnly={readOnly}
aria-invalid={fieldError("tskReportDueDay") ? true : undefined}
aria-describedby={describedByFor("note-due-day", ...(fieldError("tskReportDueDay") ? ["error-due-day"] : []))}
onChange={(event) => set("tskReportDueDay", event.target.value)}
/>
<AssumptionNote id="note-due-day" />
{fieldError("tskReportDueDay") ? (
<p id="error-due-day" role="alert" className="text-sm text-destructive">
{fieldError("tskReportDueDay")}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-member">TSK-Mitgliedsnummer</Label>
<Input
id="tsk-member"
value={values.tskMemberNumber}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("tskMemberNumber", event.target.value)}
/>
</div>
</FormSection>
<FormSection title="Erweitert" collapsible defaultOpen={false} fieldCount={2}>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Gezählte Völkerarten</legend>
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
<div className="flex flex-wrap gap-2">
{COLONY_TYPES.map((type) => (
<ToggleChip
key={type}
label={COLONY_TYPE_LABELS[type]}
checked={values.counted.includes(type)}
disabled={readOnly}
onChange={(checked) => toggleCounted(type, checked)}
/>
))}
</div>
</fieldset>
<DigitRulesField
rows={values.rules}
readOnly={readOnly}
noteId="note-digit-rules"
problems={ruleProblems}
onChange={(rules) => set("rules", rules)}
/>
</FormSection>
<div aria-live="assertive">
{general ? (
<p role="alert" className="text-sm font-medium text-destructive">
{general}
</p>
) : null}
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm font-medium">
{saved}
</p>
{canManage ? (
<>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</>
) : null}
</form>
</main>
);
}
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { updateHarvest } from "@/lib/harvests/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import type { Harvest } from "@/lib/harvests/types";
import { HarvestEditDialog } from "./harvest-edit-dialog";
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ updateHarvest: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const harvest = {
id: "h-1",
apiaryId: "apiary-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
honeyType: null,
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
createdAt: "",
colonies: [{ id: "x", colonyId: "colony-1", colony: { id: "colony-1", code: "V-01" } }],
} as Harvest;
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HarvestEditDialog harvest={harvest} />
</QueryClientProvider>,
);
}
describe("HarvestEditDialog – Sperrbezirk", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Garten" },
{ id: "apiary-2", name: "Obstwiese" },
] as never);
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(updateHarvest).mockReset();
vi.mocked(updateHarvest).mockResolvedValue(harvest);
});
it("warns about Auflagen for honey and combs when the apiary is restricted, and still saves", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-1",
apiaryId: "apiary-1",
apiaryName: "Garten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
const dialog = await screen.findByRole("dialog");
expect(
await within(dialog).findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
expect(updateHarvest).toHaveBeenCalledTimes(1);
});
it("shows no warning for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
const dialog = await screen.findByRole("dialog");
await vi.waitFor(() => expect(fetchActiveRestrictions).toHaveBeenCalled());
expect(within(dialog).queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -22,6 +22,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient();
@@ -117,6 +118,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
<HarvestRestrictionWarning apiaryId={apiaryId} />
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
@@ -6,6 +6,7 @@ import NewHarvestPage from "./page";
import { createHarvest } from "@/lib/harvests/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { toast } from "sonner";
@@ -15,6 +16,10 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("apiaryId=apiary-1"),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() }));
@@ -171,3 +176,43 @@ describe("NewHarvestPage", () => {
expect(push).toHaveBeenCalledWith("/harvests/h-1");
});
});
describe("NewHarvestPage – Sperrbezirk", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(createHarvest).mockReset();
vi.mocked(createHarvest).mockResolvedValue({ id: "h-1" } as never);
});
it("warns that Auflagen apply to honey and combs - and still saves", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-1",
apiaryId: "apiary-1",
apiaryName: "Garten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
renderPage();
expect(
await screen.findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
await userEvent.click(await screen.findByRole("checkbox", { name: "V-01" }));
const save = screen.getByRole("button", { name: "Ernte speichern" });
expect(save).toBeEnabled();
await userEvent.click(save);
expect(createHarvest).toHaveBeenCalledTimes(1);
});
it("shows no warning for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
expect(screen.queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -28,6 +28,7 @@ import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
import {
HarvestFormValues,
initialHarvestValues,
@@ -212,6 +213,7 @@ export default function NewHarvestPage() {
/>
)}
</Field>
<HarvestRestrictionWarning apiaryId={values.apiaryId} />
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, onTestFinished, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import NewTreatmentPage from "./page";
@@ -11,6 +11,10 @@ import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { todayLocalIsoDate } from "@/lib/dates";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -32,7 +36,16 @@ vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
listPacks: vi.fn().mockResolvedValue([]),
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
const granted = vi.hoisted(() => new Set<string>());
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => granted.has(permission) }),
}));
vi.mock("@/lib/medicines/api", () => ({
fetchMedicines: vi.fn(),
fetchSelectableLots: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
@@ -72,6 +85,10 @@ describe("NewTreatmentPage", () => {
.mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
granted.clear();
vi.mocked(fetchMedicines).mockReset().mockResolvedValue([]);
vi.mocked(fetchSelectableLots).mockReset().mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockReset().mockResolvedValue({ daily: [] });
});
afterEach(() => vi.restoreAllMocks());
@@ -502,4 +519,441 @@ describe("NewTreatmentPage", () => {
expect(keys[2]).not.toBe(keys[0]);
});
});
describe("with a medicine from the catalogue", () => {
const OXUVAR = medicine({
id: "m-oxuvar",
name: "Oxuvar",
unit: "ML",
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
applicationMethod: "Träufeln",
dosePerColony: 30,
minTemperatureC: 12,
maxTemperatureC: 25,
});
const APIVAR = medicine({ id: "m-apivar", name: "Apivar", unit: "PIECE", withdrawalPeriodDays: null });
const OLD_LOT = lot({
id: "l-old",
medicineId: "m-oxuvar",
unit: "ML",
batchNumber: "4711",
remaining: 180,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
});
const NEW_LOT = lot({
id: "l-new",
medicineId: "m-oxuvar",
unit: "ML",
batchNumber: "4800",
remaining: 500,
expiresOn: "2028-01-31",
});
const SAVE = { name: "Behandlung speichern" };
const SOURCE_LINE = "aus Packung: Ch.-B. 4711 · Lieferant Tierarzt Muster · Beleg Rechnung 4711";
const PRESELECT_NOTE = "Die Packung mit dem frühesten Verfall ist vorgewählt.";
const TWO_COLONIES = [
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "WEAK", apiaryId: "apiary-1" },
];
beforeEach(() => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR]);
vi.mocked(fetchSelectableLots).mockResolvedValue([OLD_LOT, NEW_LOT]);
});
async function chooseMedicine(name: string) {
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name }));
}
async function chooseBulkApiary() {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES as never);
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
await screen.findByText("2 von 2 Völkern gewählt");
}
it("offers the catalogue medicines and the last option „Anderes Mittel“ with the free-text field as today", async () => {
renderPage();
expect(screen.getByLabelText(/^Mittel/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
const names = (await screen.findAllByRole("option")).map((option) => option.textContent);
expect(names).toEqual(["Oxuvar", "Apivar", "Anderes Mittel"]);
expect(screen.getByLabelText(/^Mittel/)).toBeInTheDocument();
});
it("shows only „Anderes Mittel“ without veterinary-medicines:read and raises no error", async () => {
granted.clear();
renderPage();
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"Anderes Mittel",
]);
expect(fetchMedicines).not.toHaveBeenCalled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
});
it("says so and keeps „Anderes Mittel“ when the catalogue cannot be loaded", async () => {
vi.mocked(fetchMedicines).mockRejectedValue(new ApiError(500, "Fehler"));
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
expect(await screen.findByText(/konnten nicht geladen werden/)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
});
it("a catalogue medicine prefills ingredient, withdrawal, method and dose with the note „aus Katalog“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getByLabelText(/Methode/)).toHaveValue("Träufeln");
expect(screen.getByLabelText(/Dosis/)).toHaveValue("30");
expect(screen.getAllByText("aus Katalog")).toHaveLength(4);
expect(screen.queryByLabelText(/^Mittel/)).not.toBeInTheDocument();
});
it("the catalogue prefill wins over the last-treatment prefill for the same field", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue({
sourceTreatmentId: "t-0",
sourceDate: "2026-08-12T00:00:00.000Z",
product: "Oxuvar",
activeIngredient: "Alte Säure",
withdrawalPeriodDays: 21,
supplier: "Imkereibedarf Muster",
purchaseProof: null,
prescribingVet: null,
});
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Alte Säure"), {
timeout: 3000,
});
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getAllByText("aus Katalog")).toHaveLength(4);
expect(screen.queryByText(/aus letzter Behandlung mit Oxuvar vom 12\.08\.2026/)).not.toBeInTheDocument();
});
it("never overwrites a field the user typed", async () => {
renderPage();
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "Eigener Stoff");
await chooseMedicine("Oxuvar");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Eigener Stoff");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
});
it("takes the catalogue values back when „Anderes Mittel“ is chosen again", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await chooseMedicine("Anderes Mittel");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("");
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("");
expect(screen.queryByText("aus Katalog")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Dosis/)).not.toBeInTheDocument();
});
it("preselects the oldest lot and says so in a note", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent(
"Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027",
),
);
expect(screen.getByText(PRESELECT_NOTE)).toBeInTheDocument();
expect(fetchSelectableLots).toHaveBeenCalledWith("m-oxuvar", undefined);
});
it("shows lots as „Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
const labels = (await screen.findAllByRole("option")).map((option) => option.textContent);
expect(labels).toEqual([
"Ohne Packung (nichts abbuchen)",
"Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027",
"Ch.-B. 4800 · Rest 500 ml · verw. bis 01/2028",
]);
});
it("replaces supplier, proof and vet by one line „aus Packung: …“ when a lot is chosen", async () => {
renderPage();
expect(screen.getByLabelText(/Lieferant/)).toBeInTheDocument();
await chooseMedicine("Oxuvar");
expect(await screen.findByText(SOURCE_LINE)).toBeInTheDocument();
expect(screen.queryByLabelText(/Lieferant/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Beleg/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Tierarzt/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Chargennummer/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ }));
expect(screen.getByLabelText(/Lieferant/)).toBeInTheDocument();
expect(screen.getByLabelText(/Chargennummer/)).toBeInTheDocument();
});
it("does not preselect again after „Ohne Packung“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ }));
await waitFor(() => expect(screen.queryByText(PRESELECT_NOTE)).not.toBeInTheDocument());
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
expect(screen.queryByText(/Bucht/)).not.toBeInTheDocument();
});
it("shows the booking line above Speichern", async () => {
renderPage();
await chooseMedicine("Oxuvar");
const line = await screen.findByText("Bucht 30 ml je Volk ab.");
const save = screen.getByRole("button", SAVE);
expect(line.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("multiplies the booking line by the number of colonies in bulk mode", async () => {
renderPage();
await chooseBulkApiary();
await chooseMedicine("Oxuvar");
expect(await screen.findByText("Bucht 30 ml je Volk ab (× 2 = 60 ml).")).toBeInTheDocument();
});
it("warns before saving when the lot is expired or too small, but does not block", async () => {
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
expect(screen.queryByText(/abgelaufen|reicht nicht/)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2027-04-01" } });
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "200");
expect(
await screen.findByText(/Die Packung ist abgelaufen \(verw\. bis 03\/2027\)\./),
).toBeInTheDocument();
expect(
screen.getByText(/Die Packung reicht nicht: Rest 180 ml, gebraucht 200 ml\./),
).toBeInTheDocument();
expect(screen.getByText(/Speichern ist trotzdem möglich/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
});
it("shows the temperature hint with a warning icon when today is outside the catalogue window", async () => {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES.slice(0, 1) as never);
granted.add("apiaries:read");
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [{ date: todayLocalIsoDate(), tempMinC: 9, tempMaxC: 17 }],
} as never);
renderPage();
await chooseMedicine("Oxuvar");
const hint = await screen.findByText("Heute 9–17 °C. Laut Katalog 12–25 °C.");
expect(hint.closest("[data-warn='true']")).not.toBeNull();
expect(screen.getByText("Außerhalb des Katalog-Bereichs")).toBeInTheDocument();
expect(fetchApiaryWeather).toHaveBeenCalledWith("apiary-1");
});
it("shows no temperature hint and no error when the weather is missing", async () => {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES.slice(0, 1) as never);
granted.add("apiaries:read");
vi.mocked(fetchApiaryWeather).mockRejectedValue(new ApiError(404, "Kein Wetter"));
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await waitFor(() => expect(fetchApiaryWeather).toHaveBeenCalled());
expect(screen.queryByText(/Laut Katalog/)).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("sends medicineId, medicineLotId and doseQuantity and omits the lot-sourced text fields", async () => {
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(SOURCE_LINE);
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "2,5");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({
colonyId: "colony-1",
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 2.5,
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
});
for (const omitted of ["product", "supplier", "purchaseProof", "batchNumber", "prescribingVet"]) {
expect(input).not.toHaveProperty(omitted);
}
});
it("keeps the typed text fields when a catalogue medicine is chosen without a pack", async () => {
vi.mocked(fetchSelectableLots).mockResolvedValue([]);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
expect(await screen.findByText(/Keine Packung mit Rest/)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/Lieferant/), "Imkereibedarf Muster");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({ medicineId: "m-oxuvar", supplier: "Imkereibedarf Muster" });
expect(input).not.toHaveProperty("medicineLotId");
});
it("refuses a dose below 0,001 or an ambiguous one and asks for it again", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "0");
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText("Die Dosis muss mindestens 0,001 sein.")).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis/)).toHaveFocus();
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "1.000");
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText(/ohne Tausenderpunkt/)).toBeInTheDocument();
expect(createTreatment).not.toHaveBeenCalled();
});
it("bulk mode sends one POST /treatments/bulk with the shared medicine fields", async () => {
vi.mocked(createTreatmentsBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
renderPage();
await chooseBulkApiary();
await chooseMedicine("Oxuvar");
await screen.findByText(SOURCE_LINE);
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatmentsBulk).mock.calls[0];
expect(input).toMatchObject({
colonyIds: ["colony-1", "colony-2"],
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 30,
});
expect(input).not.toHaveProperty("supplier");
expect(input).not.toHaveProperty("product");
expect(createTreatment).not.toHaveBeenCalled();
});
it("a changed body after an error gets a new Idempotency-Key", async () => {
vi.mocked(createTreatment).mockRejectedValue(
new ApiError(400, "Diese Angabe kommt aus der Packung"),
);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "20");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(2));
const keys = vi.mocked(createTreatment).mock.calls.map(([, options]) => options?.idempotencyKey);
expect(keys[1]).not.toBe(keys[0]);
});
});
describe("restoring a draft", () => {
function storeDraft(medicineId: string) {
window.localStorage.setItem(
"apicore:draft:user-1:treatment-new",
JSON.stringify({
savedAt: Date.now(),
values: {
colonyId: "colony-1",
date: "2026-08-01",
product: "Oxuvar",
medicineId,
medicineLotId: "l-old",
doseQuantity: "30",
},
}),
);
}
it("drops a medicine that is switched off since and says so", async () => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([
medicine({ id: "m-oxuvar", name: "Oxuvar", active: false }),
]);
storeDraft("m-oxuvar");
renderPage();
await waitFor(() => expect(fetchMedicines).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 50));
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(
await screen.findByText(/Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv/),
).toBeInTheDocument();
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("Oxuvar");
expect(screen.queryByRole("combobox", { name: /Packung/ })).not.toBeInTheDocument();
});
it("keeps a medicine that is still active", async () => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([
medicine({ id: "m-oxuvar", name: "Oxuvar", unit: "ML" }),
]);
storeDraft("m-oxuvar");
renderPage();
await waitFor(() => expect(fetchMedicines).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 50));
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(await screen.findByRole("combobox", { name: /Packung/ })).toBeInTheDocument();
expect(screen.queryByText(/nicht mehr aktiv/)).not.toBeInTheDocument();
});
});
});
@@ -2,9 +2,12 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { createTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { createTreatmentsBulk } from "@/lib/bulk/api";
@@ -15,13 +18,24 @@ import { queueTreatment } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { doseError } from "@/lib/treatments/dose";
import { lotSourceLine } from "@/lib/treatments/lot-text";
import { useCataloguePrefill } from "@/lib/treatments/use-catalogue-prefill";
import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { usePrefilledFields } from "@/lib/treatments/use-prefilled-fields";
import { PrefillNote, describedByWith } from "@/components/treatments/prefill-note";
import {
BookingNotes,
CatalogueNote,
MedicineLotFields,
describedByCatalogue,
} from "@/components/treatments/medicine-lot-fields";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import type { MedicineView } from "@/lib/medicines/types";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
@@ -36,14 +50,21 @@ import {
toTreatmentInput,
} from "./treatment-form-values";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product" | "doseQuantity";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const SAVE_FAILED = "Behandlung konnte nicht gespeichert werden.";
const GONE_MEDICINE_NOTE =
"Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.";
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“.";
function requiredKeys(values: TreatmentFormValues): RequiredKey[] {
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "product"];
return [
values.target === "bulk" ? "bulkColonies" : "colonyId",
"date",
values.medicineId ? "doseQuantity" : "product",
];
}
function validate(values: TreatmentFormValues): FieldErrors {
@@ -54,7 +75,8 @@ function validate(values: TreatmentFormValues): FieldErrors {
errors.colonyId = "Bitte ein Volk auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (!values.product.trim()) errors.product = "Bitte das Mittel angeben.";
if (!values.medicineId && !values.product.trim()) errors.product = "Bitte das Mittel angeben.";
if (values.medicineId) errors.doseQuantity = doseError(values.doseQuantity) ?? undefined;
return errors;
}
@@ -72,11 +94,20 @@ export default function NewTreatmentPage() {
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [medicineNote, setMedicineNote] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const draft = useFormDraft("treatment-new", values, dirty);
const { prefilled, markEdited, undo: undoPrefill, reset: resetPrefill } =
usePrefilledFields(values, setValues);
const catalogue = useCataloguePrefill(setValues, markEdited);
const { hasPermission } = useAuth();
const choice = useMedicineChoice({
canRead: hasPermission("veterinary-medicines:read"),
medicineId: values.medicineId,
medicineLotId: values.medicineLotId,
onPreselect: (lotId) => setValues((current) => ({ ...current, medicineLotId: lotId })),
});
function set<K extends keyof TreatmentFormValues>(
key: K,
@@ -85,15 +116,49 @@ export default function NewTreatmentPage() {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
markEdited(key);
catalogue.edited(key);
// Whatever was wrong with the field is wrong no longer; the colonies of a
// bulk capture are one field in three values.
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
setFieldErrors((current) => ({ ...current, [field]: undefined }));
}
/** A catalogue medicine was chosen (`null`: „Anderes Mittel“); its values are suggestions. */
function chooseMedicine(medicine: MedicineView | null) {
choice.undecideLot();
setMedicineNote(null);
setDirty(true);
setValues((current) => ({
...current,
medicineId: medicine?.id ?? "",
medicineLotId: "",
// The trade name follows the catalogue; „Anderes Mittel“ starts empty.
product: medicine ? medicine.name : current.medicineId ? "" : current.product,
}));
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
catalogue.apply(medicine);
}
function chooseLot(lotId: string) {
choice.decideLot();
set("medicineLotId", lotId);
}
const isBulk = values.target === "bulk";
const hasLot = values.medicineLotId !== "";
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const weatherApiaryId = isBulk
? values.bulkApiaryId
: (coloniesQuery.data?.find((colony) => colony.id === values.colonyId)?.apiaryId ?? null);
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
/** A booked dose changes the rest of the pack and of the medicine. */
function invalidateMedicineStock(medicineLotId: string | null | undefined) {
if (!medicineLotId) return;
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
}
const bulkCapture = useBulkCapture({
kind: "treatments",
create: createTreatmentsBulk,
@@ -101,6 +166,7 @@ export default function NewTreatmentPage() {
onSaved: (outcome, input) => {
draft.clear();
const count = input.colonyIds.length;
invalidateMedicineStock(input.medicineLotId);
if (outcome.via === "queued") {
toast.info(bulkQueuedText("Behandlung", count));
} else {
@@ -118,8 +184,11 @@ export default function NewTreatmentPage() {
create: createTreatment,
queue: queueTreatment,
failureMessage: "Behandlung konnte nicht gespeichert werden.",
// A changed body after an error (the pack, the dose) is another booking.
keyPerSubmission: true,
onSaved: (outcome, input) => {
draft.clear();
invalidateMedicineStock(input.medicineLotId);
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
@@ -169,8 +238,20 @@ export default function NewTreatmentPage() {
const restored = draft.restore();
if (restored) {
// A draft from before the bulk choice existed lacks its values.
setValues({ ...initialTreatmentValues(""), ...restored });
let full = { ...initialTreatmentValues(""), ...restored };
// A medicine that is gone or switched off since is not booked silently.
const known = choice.allMedicines?.find((medicine) => medicine.id === full.medicineId);
const isGone =
full.medicineId !== "" &&
(!choice.canRead || (choice.allMedicines !== undefined && !known?.active));
if (isGone) {
full = { ...full, medicineId: "", medicineLotId: "" };
}
setMedicineNote(isGone ? GONE_MEDICINE_NOTE : null);
setValues(full);
resetPrefill();
catalogue.resetFrom(full);
if (full.medicineId) choice.decideLot();
setDirty(true);
}
}
@@ -235,25 +316,24 @@ export default function NewTreatmentPage() {
/>
)}
</Field>
<Field
label="Mittel"
required
error={fieldErrors.product}
hint="Handelsname, z. B. „Oxuvar“"
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="product"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.product}
onChange={(event) => set("product", event.target.value)}
/>
)}
</Field>
<div role="status" aria-live="polite" className="empty:hidden">
{medicineNote ? <p className="rounded-lg bg-warning/20 px-3 py-2 text-sm">{medicineNote}</p> : null}
</div>
<MedicineLotFields
idPrefix="treatment"
choice={choice}
product={values.product}
medicineId={values.medicineId}
medicineLotId={values.medicineLotId}
doseQuantity={values.doseQuantity}
productError={fieldErrors.product}
doseError={fieldErrors.doseQuantity}
filled={catalogue.filled}
onProductChange={(product) => set("product", product)}
onMedicineChange={chooseMedicine}
onLotChange={chooseLot}
onDoseChange={(dose) => set("doseQuantity", dose)}
/>
</FormSection>
<FormSection step={2} title="Mittel & Anwendung">
@@ -265,7 +345,11 @@ export default function NewTreatmentPage() {
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.activeIngredient)}
aria-describedby={describedByWith(
id,
describedByCatalogue(id, describedBy, catalogue.filled.activeIngredient),
prefilled.activeIngredient,
)}
className="h-11"
value={values.activeIngredient}
onChange={(event) =>
@@ -273,6 +357,7 @@ export default function NewTreatmentPage() {
}
/>
<PrefillNote id={id} source={prefilled.activeIngredient} />
<CatalogueNote id={id} shown={catalogue.filled.activeIngredient} />
</>
)}
</Field>
@@ -289,29 +374,34 @@ export default function NewTreatmentPage() {
</Field>
<Field label="Methode" hint="z. B. Träufeln, Verdunsten">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.method}
onChange={(event) => set("method", event.target.value)}
/>
)}
</Field>
<Field
label="Chargennummer"
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.batchNumber}
onChange={(event) => set("batchNumber", event.target.value)}
/>
<>
<Input
id={id}
aria-describedby={describedByCatalogue(id, describedBy, catalogue.filled.method)}
className="h-11"
value={values.method}
onChange={(event) => set("method", event.target.value)}
/>
<CatalogueNote id={id} shown={catalogue.filled.method} />
</>
)}
</Field>
{hasLot ? null : (
<Field
label="Chargennummer"
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.batchNumber}
onChange={(event) => set("batchNumber", event.target.value)}
/>
)}
</Field>
)}
<Field
label="Wartezeit (Tage)"
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
@@ -320,7 +410,11 @@ export default function NewTreatmentPage() {
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.withdrawalPeriodDays)}
aria-describedby={describedByWith(
id,
describedByCatalogue(id, describedBy, catalogue.filled.withdrawalPeriodDays),
prefilled.withdrawalPeriodDays,
)}
type="number"
inputMode="numeric"
min={0}
@@ -331,6 +425,7 @@ export default function NewTreatmentPage() {
}
/>
<PrefillNote id={id} source={prefilled.withdrawalPeriodDays} />
<CatalogueNote id={id} shown={catalogue.filled.withdrawalPeriodDays} />
</>
)}
</Field>
@@ -341,50 +436,58 @@ export default function NewTreatmentPage() {
title="Für das Bestandsbuch"
description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.supplier)}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.supplier} />
</>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) => set("purchaseProof", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.purchaseProof} />
</>
)}
</Field>
<Field label="Verschreibender Tierarzt (falls zutreffend)">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)}
className="h-11"
value={values.prescribingVet}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.prescribingVet} />
</>
)}
</Field>
{hasLot ? (
<p className="rounded-lg bg-muted/60 px-3 py-2 text-sm">
{choice.lot ? lotSourceLine(choice.lot) : "aus Packung"}
</p>
) : (
<>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.supplier)}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.supplier} />
</>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) => set("purchaseProof", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.purchaseProof} />
</>
)}
</Field>
<Field label="Verschreibender Tierarzt (falls zutreffend)">
{({ id }) => (
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)}
className="h-11"
value={values.prescribingVet}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.prescribingVet} />
</>
)}
</Field>
</>
)}
{Object.keys(prefilled).length > 0 ? (
<Button
type="button"
@@ -453,23 +556,33 @@ export default function NewTreatmentPage() {
<StickySubmit
note={
isBulk ? (
<BulkSubmitNote
submitError={bulkCapture.submitError}
isRetryLater={bulkCapture.isRetryLater}
invalidColonyIds={bulkCapture.invalidColonyIds}
colonies={apiaryColonies.data?.items}
isPending={bulkCapture.isPending}
onResendWithout={resendWithoutRefused}
<>
<BookingNotes
choice={choice}
doseQuantity={values.doseQuantity}
colonyCount={isBulk ? values.bulkColonyIds.length : 1}
treatmentDay={values.date}
apiaryId={weatherApiaryId}
canReadApiaries={hasPermission("apiaries:read")}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{capture.submitError}
</p>
) : null
{isBulk ? (
<BulkSubmitNote
submitError={bulkCapture.submitError}
isRetryLater={bulkCapture.isRetryLater}
invalidColonyIds={bulkCapture.invalidColonyIds}
colonies={apiaryColonies.data?.items}
isPending={bulkCapture.isPending}
onResendWithout={resendWithoutRefused}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{saveErrorText(capture.error, SAVE_FAILED)}
</p>
) : null}
</>
}
>
<Button
@@ -3,6 +3,7 @@ import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { parsedDose } from "@/lib/treatments/dose";
/** Everything the form holds, as plain values that survive a draft save. */
export interface TreatmentFormValues {
@@ -15,6 +16,12 @@ export interface TreatmentFormValues {
startDate: string;
endDate: string;
product: string;
/** Catalogue medicine ("" = Anderes Mittel, typed by hand). */
medicineId: string;
/** Chosen pack ("" = none; nothing is booked then). */
medicineLotId: string;
/** Dose per colony in the unit of the medicine, as typed ("2,5"). */
doseQuantity: string;
activeIngredient: string;
amount: string;
method: string;
@@ -37,6 +44,9 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
startDate: "",
endDate: "",
product: "",
medicineId: "",
medicineLotId: "",
doseQuantity: "",
activeIngredient: "",
amount: "",
method: "",
@@ -50,23 +60,46 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
};
}
/**
* The fields the pack supplies. With a pack they are left out of the payload
* (the server answers 400 for text there); without one they are the typed text.
*/
function ownSourceFields(values: TreatmentFormValues) {
if (values.medicineLotId) return {};
return {
batchNumber: emptyToNull(values.batchNumber),
supplier: emptyToNull(values.supplier),
purchaseProof: emptyToNull(values.purchaseProof),
prescribingVet: emptyToNull(values.prescribingVet),
};
}
/** Catalogue medicine, pack and dose; empty choices are left out of a new treatment. */
function medicineFields(values: TreatmentFormValues) {
const dose = parsedDose(values.doseQuantity);
return {
...(values.medicineId ? { medicineId: values.medicineId } : {}),
...(values.medicineLotId ? { medicineLotId: values.medicineLotId } : {}),
...(values.medicineId && dose !== null ? { doseQuantity: dose } : {}),
};
}
/** Everything of the treatment that is the same for one colony or for many. */
function sharedTreatmentFields(values: TreatmentFormValues) {
return {
date: toApiDate(values.date),
startDate: emptyToNull(values.startDate, toApiDate),
endDate: emptyToNull(values.endDate, toApiDate),
product: values.product.trim(),
// A catalogue medicine gives the trade name itself.
...(values.medicineId ? {} : { product: values.product.trim() }),
...medicineFields(values),
activeIngredient: emptyToNull(values.activeIngredient),
amount: emptyToNull(values.amount),
method: emptyToNull(values.method),
reason: emptyToNull(values.reason),
batchNumber: emptyToNull(values.batchNumber),
notes: emptyToNull(values.notes),
withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number),
supplier: emptyToNull(values.supplier),
purchaseProof: emptyToNull(values.purchaseProof),
prescribingVet: emptyToNull(values.prescribingVet),
...ownSourceFields(values),
};
}
@@ -76,7 +109,7 @@ export function toTreatmentInput(
return { colonyId: values.colonyId, ...sharedTreatmentFields(values) };
}
/** One treatment for the chosen colonies; the batch number is what the user typed. */
/** One treatment for the chosen colonies; with a pack each colony books its own dose. */
export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput {
return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds };
}