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 };
}
@@ -0,0 +1,18 @@
import Link from "next/link";
/** Shown instead of a page whose data needs `compliance:read`: a retry would not help. */
export function NoComplianceAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
<Link
href="/dashboard"
className="inline-flex min-h-11 items-center self-start text-sm underline underline-offset-2"
>
Zurück zum Start
</Link>
</main>
);
}
@@ -0,0 +1,27 @@
import { CheckCircle2, Circle, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { NOTICE_STATUS_LABEL } from "@/lib/compliance/notice-labels";
import type { AuthorityNoticeStatus } from "@/lib/compliance/notice-types";
const STATUS_ICON: Record<AuthorityNoticeStatus, LucideIcon> = {
DRAFT: Circle,
SENT: CheckCircle2,
CANCELLED: XCircle,
};
const STATUS_VARIANT: Record<AuthorityNoticeStatus, "outline" | "default" | "secondary"> = {
DRAFT: "outline",
SENT: "default",
CANCELLED: "secondary",
};
/** The status of a notice as symbol AND word, never colour alone. */
export function NoticeStatus({ status }: { status: AuthorityNoticeStatus }) {
const Icon = STATUS_ICON[status];
return (
<Badge variant={STATUS_VARIANT[status]} className="h-6 gap-1.5 px-2.5 text-sm">
<Icon aria-hidden />
{NOTICE_STATUS_LABEL[status]}
</Badge>
);
}
@@ -0,0 +1,21 @@
"use client";
import type { RestrictionGuard } from "@/lib/compliance/use-restriction-guard";
import { RestrictionNotice } from "./restriction-notice";
import { RestrictionOverride } from "./restriction-override";
/** The red notice and, only while something blocks, the folded override. */
export function RestrictionGuardPanel({
guard,
blockedText,
}: {
guard: RestrictionGuard;
blockedText: string;
}) {
return (
<>
<RestrictionNotice live id={guard.noticeId} restrictions={guard.blocking} blockedText={blockedText} />
{guard.isBlocked ? <RestrictionOverride key={guard.resetKey} onReason={guard.setReason} /> : null}
</>
);
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import {
HarvestRestrictionWarning,
MoveHint,
RestrictionBand,
RestrictionNotice,
} from "./restriction-notice";
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn(),
}));
const restriction = {
id: "r-1",
apiaryId: "a-1",
apiaryName: "Obstwiese",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
};
describe("RestrictionNotice", () => {
it("names the apiary, the disease, the start day and the blocked action in red with a symbol", () => {
render(<RestrictionNotice id="notice" restrictions={[restriction]} />);
const text = screen.getByText(
"Stand Obstwiese liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Umsetzen ist gesperrt.",
);
const box = text.closest("[data-restriction-notice]");
expect(box).toHaveClass("text-destructive");
expect(box?.querySelector("svg")).not.toBeNull();
expect(box).toHaveAttribute("id", "notice");
});
it("says one sentence per restricted apiary and takes another blocked action", () => {
render(
<RestrictionNotice
blockedText="Anlegen ist gesperrt."
restrictions={[restriction, { ...restriction, id: "r-2", apiaryName: "Garten", disease: "Kleiner Beutenkäfer" }]}
/>,
);
expect(screen.getAllByText(/liegt im Sperrbezirk/)).toHaveLength(2);
expect(screen.getAllByText(/Anlegen ist gesperrt\.$/)).toHaveLength(2);
});
it("renders nothing without restrictions", () => {
const { container } = render(<RestrictionNotice restrictions={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("MoveHint", () => {
it("shows the fixed BienSeuchV § 5 hint", () => {
render(<MoveHint />);
expect(
screen.getByText(
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.",
),
).toBeInTheDocument();
});
});
describe("RestrictionBand", () => {
it("is a red band with a link to the list when the reader may open it", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList />);
expect(screen.getByText(/liegt im Sperrbezirk/).closest("[data-restriction-band]")).toHaveClass(
"text-destructive",
);
expect(screen.getByRole("link", { name: "Sperrbezirke ansehen" })).toHaveAttribute(
"href",
"/compliance/restrictions",
);
});
it("has no link without the right to read the list", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList={false} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
describe("HarvestRestrictionWarning", () => {
beforeEach(() => {
vi.mocked(fetchActiveRestrictions).mockReset();
});
function renderWarning(apiaryId: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HarvestRestrictionWarning apiaryId={apiaryId} />
</QueryClientProvider>,
);
}
it("warns only for a restricted apiary and keeps a polite live region either way", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
const { container } = renderWarning("a-1");
expect(
await screen.findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
expect(container.querySelector("[aria-live=polite]")).not.toBeNull();
});
it("says nothing for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
renderWarning("a-2");
await vi.waitFor(() => expect(fetchActiveRestrictions).toHaveBeenCalled());
expect(screen.queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { AlertTriangle, OctagonAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { restrictionsAmong, type ActiveRestriction } from "@/lib/compliance/restrictions-api";
import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
import { formatDay } from "@/lib/medicines/format";
/** The blocked action, worded for the place the notice appears in. */
export const BLOCKED_TEXT = {
move: "Umsetzen ist gesperrt.",
create: "Anlegen ist gesperrt.",
merge: "Vereinigen ist gesperrt.",
correct: "Standwechsel ist gesperrt.",
} as const;
const MOVE_HINT =
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.";
function sentenceOf(restriction: ActiveRestriction): string {
return `Stand ${restriction.apiaryName} liegt im Sperrbezirk (${restriction.disease}, seit ${formatDay(restriction.startsOn)}).`;
}
interface RestrictionNoticeProps {
restrictions: readonly ActiveRestriction[];
/** The sentence that says what is blocked; defaults to the move. */
blockedText?: string;
/** Lets a disabled button point at the notice (`aria-describedby`). */
id?: string;
/**
* Wraps the notice in a polite live region that stays in the page, so the
* notice is announced when it appears. Without it the notice is just a box.
*/
live?: boolean;
}
/**
* The red box: symbol, the word "Sperrbezirk" and the text in the
* `destructive` colour - never colour alone. One sentence per restriction.
*/
export function RestrictionNotice({
restrictions,
blockedText = BLOCKED_TEXT.move,
id,
live = false,
}: RestrictionNoticeProps) {
const box =
restrictions.length === 0 ? null : (
<div
id={id}
data-restriction-notice
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive"
>
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>{`${sentenceOf(restriction)} ${blockedText}`}</p>
))}
</div>
</div>
);
return live ? <div aria-live="polite">{box}</div> : box;
}
/** Fixed hint of the move dialog; the wording is prescribed, not configurable. */
export function MoveHint() {
return <p className="text-sm text-muted-foreground">{MOVE_HINT}</p>;
}
/** The red band at the top of an apiary page while a restriction is active. */
export function RestrictionBand({
restrictions,
canOpenList,
}: {
restrictions: readonly ActiveRestriction[];
/** The list needs `compliance:read`; without it no link is offered. */
canOpenList: boolean;
}) {
if (restrictions.length === 0) return null;
return (
<section
data-restriction-band
aria-label="Sperrbezirk"
className="flex flex-col gap-3 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2">
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>
{`${sentenceOf(restriction)} Völker anlegen, umsetzen oder vereinigen ist gesperrt.`}
</p>
))}
</div>
</div>
{canOpenList ? (
<Button
variant="outline"
size="touch"
render={<Link href="/compliance/restrictions" />}
>
Sperrbezirke ansehen
</Button>
) : null}
</section>
);
}
/**
* Honey and combs from a restricted zone carry conditions. Only a warning,
* never a block: the harvest is saved all the same.
*/
export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
const { restrictions } = useActiveRestrictions();
const restricted = apiaryId !== "" && restrictionsAmong([apiaryId], restrictions).length > 0;
return (
<div aria-live="polite">
{restricted ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
</p>
) : null}
</div>
);
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RestrictionOverride } from "./restriction-override";
let permissions = ["compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const REASON = "Amtstierarzt hat die Ausnahme mündlich erlaubt.";
async function open() {
await userEvent.click(screen.getByRole("button", { name: /Ausnahme mit Begründung/ }));
}
describe("RestrictionOverride", () => {
beforeEach(() => {
permissions = ["compliance:manage"];
});
it("renders nothing without compliance:manage", () => {
permissions = ["compliance:read"];
const { container } = render(<RestrictionOverride onReason={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("is folded until opened", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /Ausnahme mit Begründung/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByLabelText(/Begründung der Ausnahme/)).not.toBeInTheDocument();
await open();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByLabelText(/Begründung der Ausnahme/)).toBeInTheDocument();
});
it("keeps the confirm button off below 10 characters and reports nothing", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), "zu kurz");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
expect(onReason).not.toHaveBeenCalled();
});
it("reports the trimmed reason only after the confirmation, and takes it back", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), ` ${REASON} `);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
const dialog = await screen.findByRole("alertdialog");
expect(onReason).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme bestätigen" }));
expect(onReason).toHaveBeenLastCalledWith(REASON);
expect(screen.getByRole("status")).toHaveTextContent(REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme zurücknehmen" }));
expect(onReason).toHaveBeenLastCalledWith(null);
});
it("keeps the confirm button off for a reason of only blanks", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), " ");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
});
it("caps the reason at 500 characters", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
const field = screen.getByLabelText(/Begründung der Ausnahme/) as HTMLTextAreaElement;
expect(field).toHaveAttribute("maxlength", "500");
await userEvent.click(field);
await userEvent.paste("x".repeat(501));
expect(field.value).toHaveLength(500);
});
it("does not call onReason while the confirmation is still open", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(onReason).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { ChevronDown, ShieldCheck } from "lucide-react";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth/auth-context";
import { cn } from "@/lib/utils";
/** The server's bounds for `restrictionOverrideReason`. */
export const MIN_OVERRIDE_REASON_LENGTH = 10;
export const MAX_OVERRIDE_REASON_LENGTH = 500;
interface RestrictionOverrideProps {
/** The confirmed reason, or `null` while there is none (or it was taken back). */
onReason: (reason: string | null) => void;
}
/**
* The exception from a restricted zone: a folded section with a reason that
* counts only after a second question. Only with `compliance:manage` - the
* server answers 403 to anyone else, so the field is not offered at all.
*/
export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
const { hasPermission } = useAuth();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [confirmed, setConfirmed] = useState<string | null>(null);
if (!hasPermission("compliance:manage")) return null;
const trimmed = text.trim();
const tooShort = trimmed.length < MIN_OVERRIDE_REASON_LENGTH;
function takeBack() {
setConfirmed(null);
onReason(null);
}
return (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg text-left text-sm font-medium"
>
Ausnahme mit Begründung
<ChevronDown
aria-hidden
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
/>
</button>
{open ? (
<div className="flex flex-col gap-3">
<Field
label="Begründung der Ausnahme"
required
hint={`Mindestens ${MIN_OVERRIDE_REASON_LENGTH} Zeichen. Sie wird in der Historie festgehalten.`}
>
{({ id, describedBy }) => (
<Textarea
id={id}
aria-describedby={describedBy}
maxLength={MAX_OVERRIDE_REASON_LENGTH}
readOnly={confirmed !== null}
value={text}
onChange={(event) => setText(event.target.value)}
/>
)}
</Field>
{confirmed === null ? (
<ConfirmDialog
triggerLabel="Ausnahme nutzen"
triggerVariant="outline"
triggerSize="touch"
triggerDisabled={tooShort}
title="Ausnahme vom Sperrbezirk nutzen?"
description="Du setzt dich über den Sperrbezirk hinweg. Deine Begründung wird mit deinem Namen festgehalten. Nutze die Ausnahme nur nach Rücksprache mit dem Veterinäramt."
confirmLabel="Ausnahme bestätigen"
destructive={false}
onConfirm={() => {
setConfirmed(trimmed);
onReason(trimmed);
}}
/>
) : (
<Button type="button" variant="outline" size="touch" onClick={takeBack}>
Ausnahme zurücknehmen
</Button>
)}
</div>
) : null}
<p role="status" className="flex items-start gap-2 text-sm font-medium">
{confirmed !== null ? (
<>
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{`Ausnahme gilt: ${confirmed}`}</span>
</>
) : null}
</p>
</div>
);
}
@@ -24,6 +24,8 @@ interface ConfirmDialogProps {
triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string;
triggerDisabled?: boolean;
/** Off for now but still reachable by keyboard, so `triggerDescribedBy` can be read. */
triggerAriaDisabled?: boolean;
/** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */
triggerDescribedBy?: string;
title: string;
@@ -41,6 +43,7 @@ export function ConfirmDialog({
triggerSize = "default",
triggerClassName,
triggerDisabled,
triggerAriaDisabled,
triggerDescribedBy,
title,
description,
@@ -60,8 +63,11 @@ export function ConfirmDialog({
className={cn(triggerClassName)}
aria-label={triggerAriaLabel}
disabled={triggerDisabled}
aria-disabled={triggerAriaDisabled || undefined}
aria-describedby={triggerDescribedBy}
onClick={() => setOpen(true)}
onClick={() => {
if (!triggerAriaDisabled) setOpen(true);
}}
>
{triggerLabel}
</Button>
@@ -41,6 +41,7 @@ const CATEGORY_LABEL: Record<NotificationCategory, string> = {
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
COMPLIANCE: "Behörden",
};
/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */
@@ -0,0 +1,126 @@
import { ReactNode } from "react";
import { 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";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { BookingNotes, MedicineLotFields } from "./medicine-lot-fields";
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
const STATUS = { isLoading: false, isError: false, isRetrying: false, retry: vi.fn() };
function choiceOf(overrides: Partial<MedicineChoice> = {}): MedicineChoice {
return {
canRead: true,
medicines: [medicine({ id: "m1", name: "Apivar" })],
allMedicines: [medicine({ id: "m1", name: "Apivar" })],
medicinesStatus: STATUS,
medicine: undefined,
lots: undefined,
lotsStatus: STATUS,
lot: undefined,
preselectedLotId: null,
decideLot: vi.fn(),
undecideLot: vi.fn(),
...overrides,
};
}
function wrap(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
function fields(props: Partial<React.ComponentProps<typeof MedicineLotFields>>) {
return (
<MedicineLotFields
idPrefix="t"
choice={choiceOf()}
product=""
medicineId=""
medicineLotId=""
doseQuantity=""
filled={{}}
onProductChange={vi.fn()}
onMedicineChange={vi.fn()}
onLotChange={vi.fn()}
onDoseChange={vi.fn()}
{...props}
/>
);
}
describe("MedicineLotFields", () => {
it("hands the chosen catalogue medicine to the form", async () => {
const onMedicineChange = vi.fn();
wrap(fields({ onMedicineChange }));
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
expect(onMedicineChange).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", name: "Apivar" }));
});
it("hands null to the form for the last option", async () => {
const onMedicineChange = vi.fn();
wrap(
fields({
onMedicineChange,
medicineId: "m1",
choice: choiceOf({ medicine: medicine({ id: "m1", name: "Apivar" }) }),
}),
);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Anderes Mittel" }));
expect(onMedicineChange).toHaveBeenCalledWith(null);
});
it("names the unit of the medicine next to the dose", () => {
const apivar = medicine({ id: "m1", name: "Apivar", unit: "PIECE" });
wrap(fields({ choice: choiceOf({ medicine: apivar }), medicineId: "m1", doseQuantity: "2" }));
expect(screen.getByText("Stück")).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis/)).toHaveValue("2");
});
});
describe("BookingNotes", () => {
const pack = lot({ unit: "ML", remaining: 100, expiresOn: "2026-06-30" });
it("shows nothing without a pack", () => {
wrap(
<BookingNotes
choice={choiceOf()}
doseQuantity="30"
colonyCount={1}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.queryByText(/Bucht/)).not.toBeInTheDocument();
});
it("books, warns about expiry and shortage and says saving stays possible", () => {
wrap(
<BookingNotes
choice={choiceOf({ lot: pack })}
doseQuantity="30"
colonyCount={5}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.getByText("Bucht 30 ml je Volk ab (× 5 = 150 ml).")).toBeInTheDocument();
expect(screen.getByText(/abgelaufen/)).toBeInTheDocument();
expect(screen.getByText(/reicht nicht: Rest 100 ml, gebraucht 150 ml/)).toBeInTheDocument();
expect(screen.getByText("Speichern ist trotzdem möglich.")).toBeInTheDocument();
});
});
@@ -0,0 +1,358 @@
"use client";
import { AlertTriangle, PackageMinus, Thermometer } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
import type { LotView, MedicineView } from "@/lib/medicines/types";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { bookingLine } from "@/lib/treatments/booking-line";
import { parsedDose } from "@/lib/treatments/dose";
import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
import { temperatureHint } from "@/lib/treatments/temperature-hint";
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
import { PrefillStatus } from "@/components/warnings/prefill-status";
const OTHER_MEDICINE = { id: "__other__", name: "Anderes Mittel" } as const;
const NO_LOT_LABEL = "Ohne Packung (nichts abbuchen)";
type MedicineOption = Pick<MedicineView, "id" | "name">;
/** Visible note that a value comes from the catalogue and is still editable. */
export function CatalogueNote({ id, shown }: { id: string; shown: boolean | undefined }) {
if (!shown) return null;
return (
<p id={`${id}-catalogue`} className="text-xs font-medium text-muted-foreground">
aus Katalog
</p>
);
}
/** The control's aria-describedby, extended by the catalogue note when one is shown. */
export function describedByCatalogue(
id: string,
describedBy: string | undefined,
shown: boolean | undefined,
): string | undefined {
return [describedBy, shown ? `${id}-catalogue` : undefined].filter(Boolean).join(" ") || undefined;
}
export interface MedicineLotFieldsProps {
idPrefix: string;
choice: MedicineChoice;
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
productError?: string;
doseError?: string;
/** Which fields carry the note „aus Katalog“. */
filled: CatalogueFilled;
onProductChange: (product: string) => void;
/** `null` is „Anderes Mittel“. */
onMedicineChange: (medicine: MedicineView | null) => void;
/** `""` is „Ohne Packung“. */
onLotChange: (lotId: string) => void;
onDoseChange: (dose: string) => void;
}
/**
* „Mittel“ of a treatment: a catalogue medicine (when the user may read the
* catalogue) or any other by name, then its pack and the dose per colony.
* Everything that is chosen here stays visible and editable.
*/
export function MedicineLotFields({
idPrefix,
choice,
product,
medicineId,
medicineLotId,
doseQuantity,
productError,
doseError,
filled,
onProductChange,
onMedicineChange,
onLotChange,
onDoseChange,
}: MedicineLotFieldsProps) {
const options: MedicineOption[] = [...(choice.medicines ?? []), OTHER_MEDICINE];
const labelOptions: MedicineOption[] = [...(choice.allMedicines ?? []), OTHER_MEDICINE];
const status = choice.medicinesStatus;
return (
<>
<Field label="Aus dem Katalog wählen" id={`${idPrefix}-medicine`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineId || OTHER_MEDICINE.id}
onChange={(next) =>
onMedicineChange(
choice.medicines?.find((candidate) => candidate.id === next) ?? null,
)
}
items={options}
labelItems={labelOptions}
getLabel={(option) => option.name}
/>
<PrefillStatus
enabled={choice.canRead}
isLoading={status.isLoading}
isError={status.isError}
isRetrying={status.isRetrying}
onRetry={status.retry}
showLoading={false}
/>
</>
)}
</Field>
{medicineId === "" ? (
<Field
label="Mittel"
required
error={productError}
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={product}
onChange={(event) => onProductChange(event.target.value)}
/>
)}
</Field>
) : (
<>
<LotField
idPrefix={idPrefix}
choice={choice}
medicineLotId={medicineLotId}
onLotChange={onLotChange}
/>
<DoseField
idPrefix={idPrefix}
medicine={choice.medicine}
hasLot={medicineLotId !== ""}
value={doseQuantity}
error={doseError}
isFromCatalogue={filled.doseQuantity}
onChange={onDoseChange}
/>
</>
)}
</>
);
}
function LotField({
idPrefix,
choice,
medicineLotId,
onLotChange,
}: {
idPrefix: string;
choice: MedicineChoice;
medicineLotId: string;
onLotChange: (lotId: string) => void;
}) {
const { lots, lotsStatus } = choice;
const isEmpty = lots !== undefined && lots.length === 0;
return (
<Field label="Packung" id={`${idPrefix}-lot`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineLotId}
onChange={onLotChange}
items={lots}
getLabel={(lot: LotView) => lotLabel(lot)}
noneLabel={NO_LOT_LABEL}
dataField="medicineLotId"
/>
<div role="status" aria-live="polite" className="empty:hidden">
{lotsStatus.isLoading ? (
<p className="text-sm text-muted-foreground">Packungen werden geladen …</p>
) : null}
{choice.preselectedLotId ? (
<p className="text-xs font-medium text-muted-foreground">
Die Packung mit dem frühesten Verfall ist vorgewählt.
</p>
) : null}
{isEmpty ? (
<p className="text-sm text-muted-foreground">
Keine Packung mit Rest im Katalog. Du kannst trotzdem speichern, dann wird nichts
abgebucht.
</p>
) : null}
</div>
{lotsStatus.isError ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<span>Packungen konnten nicht geladen werden.</span>
<Button
type="button"
variant="outline"
size="touch"
onClick={lotsStatus.retry}
disabled={lotsStatus.isRetrying}
>
Erneut versuchen
</Button>
</div>
) : null}
</>
)}
</Field>
);
}
function DoseField({
idPrefix,
medicine,
hasLot,
value,
error,
isFromCatalogue,
onChange,
}: {
idPrefix: string;
medicine: MedicineView | undefined;
hasLot: boolean;
value: string;
error: string | undefined;
isFromCatalogue: boolean | undefined;
onChange: (dose: string) => void;
}) {
const unit = medicine ? MEDICINE_UNIT_LABELS[medicine.unit] : null;
const hint = hasLot
? "Je Volk. Ohne Dosis wird nichts abgebucht."
: "Je Volk, wie auf der Packungsbeilage.";
return (
<Field label="Dosis je Volk" id={`${idPrefix}-dose`} hint={hint} error={error}>
{({ id, describedBy, invalid }) => (
<>
<div className="flex items-center gap-2">
<Input
id={id}
data-field="doseQuantity"
inputMode="decimal"
autoComplete="off"
className="h-11 w-28"
aria-describedby={describedByCatalogue(id, describedBy, isFromCatalogue)}
aria-invalid={invalid}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
<CatalogueNote id={id} shown={isFromCatalogue} />
</>
)}
</Field>
);
}
export interface BookingNotesProps {
choice: MedicineChoice;
doseQuantity: string;
/** How many colonies the dose is booked for (1 for a single capture). */
colonyCount: number;
/** The treatment day "YYYY-MM-DD"; an expired pack is judged on it. */
treatmentDay: string;
/** The apiary the weather of the temperature hint belongs to. */
apiaryId: string | null;
canReadApiaries: boolean;
}
/**
* What saving does, above „Speichern“: the booking line, warnings about the
* pack (expired, too little) that never block, and the temperature hint. The
* region stays mounted so a change is announced.
*/
export function BookingNotes({
choice,
doseQuantity,
colonyCount,
treatmentDay,
apiaryId,
canReadApiaries,
}: BookingNotesProps) {
const dose = parsedDose(doseQuantity);
const { lot, medicine } = choice;
const line = bookingLine(dose, lot ? lot.unit : null, colonyCount);
const warnings = lot ? lotWarnings(lot, dose, colonyCount, treatmentDay) : [];
return (
<div role="status" aria-live="polite" className="flex flex-col gap-1 empty:hidden">
{line ? (
<p className="flex items-start gap-2 text-sm">
<PackageMinus className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{line}</span>
</p>
) : null}
{warnings.map((warning) => (
<p key={warning} className="flex items-start gap-2 text-sm font-medium">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>{warning}</span>
</p>
))}
{warnings.length > 0 ? (
<p className="text-xs text-muted-foreground">Speichern ist trotzdem möglich.</p>
) : null}
<TemperatureNote
medicine={medicine}
apiaryId={apiaryId}
canReadApiaries={canReadApiaries}
/>
</div>
);
}
function TemperatureNote({
medicine,
apiaryId,
canReadApiaries,
}: {
medicine: MedicineView | undefined;
apiaryId: string | null;
canReadApiaries: boolean;
}) {
const hasWindow = medicine !== undefined &&
(medicine.minTemperatureC !== null || medicine.maxTemperatureC !== null);
const weather = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId ?? ""),
enabled: canReadApiaries && apiaryId !== null && hasWindow,
retry: false,
});
if (!medicine || !hasWindow || !weather.data) return null;
const day = todayLocalIsoDate();
const forecast = weather.data.daily.find((entry) => toCalendarDay(entry.date) === day);
if (!forecast) return null;
const hint = temperatureHint(
{ min: forecast.tempMinC, max: forecast.tempMaxC },
{ min: medicine.minTemperatureC, max: medicine.maxTemperatureC },
);
if (!hint) return null;
const Icon = hint.warn ? AlertTriangle : Thermometer;
return (
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden />
<span>{hint.text}</span>
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
</p>
);
}
@@ -48,6 +48,12 @@ export interface CreateColonyInput {
originText?: string | null;
occupiedFrames?: number | null;
notes?: string | null;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
@@ -65,11 +71,23 @@ export interface UpdateColonyInput {
healthStatus?: ColonyCondition | null;
notes?: string | null;
status?: ColonyStatus;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
export interface MoveColonyInput {
apiaryId: string;
notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
/** Where the split colony gets its queen from - an observation, never prefilled. */
@@ -88,6 +106,12 @@ export interface SplitColonyInput {
export interface MergeColonyInput {
mergedColonyIds: string[];
notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
export interface ColonyHistory {
@@ -41,7 +41,7 @@ describe("compliance errors", () => {
});
it("builds on saveErrorText for the text line", () => {
expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new ApiError(409, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
});
@@ -70,3 +70,20 @@ describe("compliance errors", () => {
expect(readinessConflictText(new Error("x"))).toBeNull();
});
});
describe("complianceErrorText", () => {
it("shows German text for a 403 and for a server error, never Nest's English", () => {
expect(complianceErrorText(new ApiError(403, "Forbidden resource"), "x")).toBe("Dazu fehlt dir das Recht.");
expect(complianceErrorText(new ApiError(502, "Bad Gateway"), "x")).toBe(
"Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.",
);
});
it("joins a 400 array message and keeps a plain 409 text", () => {
const backend = ["tskReferenceDay muss ein Tag im Format MM-TT sein"];
expect(complianceErrorText(new ApiError(400, "x", { message: backend }), "f")).toBe(backend[0]);
expect(complianceErrorText(new ApiError(409, "Erst die Erstanzeige erfassen."), "f")).toBe(
"Erst die Erstanzeige erfassen.",
);
});
});
+24 -1
View File
@@ -91,7 +91,30 @@ export function readinessConflictText(error: unknown): string | null {
return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
}
/** One line for every other failure: the shared reader, no second error logic. */
const NO_RIGHT_TEXT = "Dazu fehlt dir das Recht.";
const SERVER_FAILED_TEXT = "Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.";
/** Nest sends `message` as an array when several DTO checks fail; join the German parts. */
function arrayMessage(error: ApiError): string | null {
const message = (error.body as { message?: unknown } | null)?.message;
if (!Array.isArray(message)) return null;
const parts = message.filter((part): part is string => typeof part === "string" && part !== "");
return parts.length > 0 ? parts.join(" ") : null;
}
/**
* One line for every other failure: the shared reader, no second error logic.
* A 403 and a server error never show Nest's English text; a 400 with an array
* message shows its parts.
*/
export function complianceErrorText(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
if (error.status === 403) return NO_RIGHT_TEXT;
if (error.status >= 500) return SERVER_FAILED_TEXT;
if (error.status === 400) {
const joined = arrayMessage(error);
if (joined) return joined;
}
}
return saveErrorText(error, fallback);
}
@@ -51,15 +51,6 @@ describe("fixLinkFor", () => {
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false);
});
it.each([
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("hides %s until its target page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(false);
});
it.each([
["holder-data", null],
["medicine-lot-gaps", { type: "lot", id: "l1" }],
@@ -67,13 +58,22 @@ describe("fixLinkFor", () => {
["label-gaps", { type: "template", id: "x1" }],
["harvest-withdrawal", { type: "harvest", id: "h1" }],
["medicine-withdrawal-missing", null],
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("shows %s, its page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(true);
});
it("is switched on per route in one set", () => {
expect(ROUTE_AVAILABLE.has("/compliance/notices")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/restrictions")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/settings")).toBe(false);
for (const route of [
"/compliance/notices",
"/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
]) {
expect(ROUTE_AVAILABLE.has(route)).toBe(true);
}
});
});
@@ -2,11 +2,14 @@ const FIX_LABEL = "Beheben";
/**
* The pages that exist. A „Beheben“ link to any other route is hidden, so
* nobody lands on a 404. Tasks 18 and 19 add their routes here to switch the
* links on: `/compliance/notices`, `/compliance/notices/[id]`,
* `/compliance/restrictions` and `/compliance/settings`.
* nobody lands on a 404. A new page adds its route here to switch the links
* on.
*/
export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([
"/compliance/notices",
"/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
"/colonies/[id]",
"/harvests/[id]",
"/medicines",
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { isValidMonthDay } from "./month-day";
describe("isValidMonthDay", () => {
it.each(["01-01", "12-31", "02-28", "04-30"])("accepts %s", (value) => {
expect(isValidMonthDay(value)).toBe(true);
});
it.each(["02-29", "04-31", "13-01", "00-10", "1-1", "01-1", "2027-01-01", "", " 01-01"])("rejects %s", (value) => {
expect(isValidMonthDay(value)).toBe(false);
});
});
@@ -0,0 +1,13 @@
/** Days per month in a non-leap year, as the backend counts them (02-29 does not exist). */
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_DAY = /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
export const MONTH_DAY_PROBLEM =
"Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.";
/** MM-TT with a real month and a day that exists in a non-leap year (mirrors the backend). */
export function isValidMonthDay(value: string): boolean {
const match = MONTH_DAY.exec(value);
if (!match) return false;
return Number(match[2]) <= DAYS_IN_MONTH[Number(match[1]) - 1];
}
@@ -0,0 +1,56 @@
import type { AuthorityNotice, ComplianceSettings } from "./notice-types";
/** Test data for notices and settings; override only what a test is about. */
export function noticeFixture(overrides: Partial<AuthorityNotice> = {}): AuthorityNotice {
return {
id: "n1",
kind: "HERD_CHANGE",
status: "DRAFT",
year: null,
referenceDate: "2027-01-10T00:00:00.000Z",
countedAt: "2027-01-10T08:00:00.000Z",
snapshot: {
version: 1,
takenAt: "2027-01-10T08:00:00.000Z",
totalLiving: 7,
apiaries: [
{
apiaryId: "a1",
name: "Waldrand",
address: "Dorfstraße 1",
location: "12345 Musterdorf",
latitude: null,
longitude: null,
livingColonies: 7,
byType: { PRODUCTION_COLONY: 5, NUCLEUS_COLONY: 2 },
},
],
},
totalColonies: 7,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
createdAt: "2027-01-10T08:00:00.000Z",
...overrides,
};
}
export function settingsFixture(overrides: Partial<ComplianceSettings> = {}): ComplianceSettings {
return {
veterinaryOfficeName: "Veterinäramt Musterstadt",
veterinaryOfficeAddress: "Amtsweg 2, 12345 Musterstadt",
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: "01-01",
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 100, minMm: 1.5 },
{ maxGrams: null, minMm: 2.5 },
],
...overrides,
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { NOTICE_KIND_LABEL, NOTICE_STATUS_LABEL, noticeDay, noticeTitle } from "./notice-labels";
import { noticeFixture } from "./notice-fixtures";
describe("notice labels", () => {
it("names every kind and status in German", () => {
expect(NOTICE_KIND_LABEL).toEqual({
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
});
expect(NOTICE_STATUS_LABEL).toEqual({ DRAFT: "Entwurf", SENT: "Gesendet", CANCELLED: "Storniert" });
});
it("reads a day from an ISO date-time as written, without a time zone shift", () => {
expect(noticeDay("2027-01-10T00:00:00.000Z")).toBe("2027-01-10");
expect(noticeDay(null)).toBeNull();
});
it("titles a TSK count with its year", () => {
expect(noticeTitle(noticeFixture({ kind: "TSK_REPORT", year: 2027 }))).toBe("TSK-Zählung 2027");
expect(noticeTitle(noticeFixture({ kind: "REGISTRATION" }))).toBe("Erstanzeige");
});
});
@@ -0,0 +1,25 @@
import type { AuthorityNotice, AuthorityNoticeKind, AuthorityNoticeStatus } from "./notice-types";
export const NOTICE_KIND_LABEL: Record<AuthorityNoticeKind, string> = {
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
};
export const NOTICE_KINDS: readonly AuthorityNoticeKind[] = ["REGISTRATION", "HERD_CHANGE", "TSK_REPORT"];
export const NOTICE_STATUS_LABEL: Record<AuthorityNoticeStatus, string> = {
DRAFT: "Entwurf",
SENT: "Gesendet",
CANCELLED: "Storniert",
};
/** The calendar day of an ISO date-time as written ("2027-01-10"); no time zone shift. */
export function noticeDay(value: string | null): string | null {
return value ? value.slice(0, 10) : null;
}
export function noticeTitle(notice: Pick<AuthorityNotice, "kind" | "year">): string {
const label = NOTICE_KIND_LABEL[notice.kind];
return notice.year === null ? label : `${label} ${notice.year}`;
}
@@ -0,0 +1,85 @@
import type { ColonyType } from "@/lib/colonies/types";
// Mirrors the backend (ApiCore-Backend/src/compliance: AuthorityNotice, HerdSnapshot, ComplianceSettings).
export type AuthorityNoticeKind = "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT";
export type AuthorityNoticeStatus = "DRAFT" | "SENT" | "CANCELLED";
export interface HerdApiarySnapshot {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
livingColonies: number;
byType: Partial<Record<ColonyType, number>>;
}
export interface HerdSnapshot {
version: number;
takenAt: string;
apiaries: HerdApiarySnapshot[];
totalLiving: number;
}
/** Days arrive as ISO date-times ("2027-01-01T00:00:00.000Z"); `noticeDay` reads the day. */
export interface AuthorityNotice {
id: string;
kind: AuthorityNoticeKind;
status: AuthorityNoticeStatus;
year: number | null;
referenceDate: string;
countedAt: string;
snapshot: HerdSnapshot;
totalColonies: number;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface CreateNoticeInput {
kind: AuthorityNoticeKind;
year?: number;
referenceDate?: string;
sentOn?: string;
}
export type NoticeAction = "recount" | "mark-sent" | "reopen" | "cancel";
export interface MarkSentInput {
sentOn?: string;
sentVia?: string;
reference?: string;
}
export interface NoticeEdit {
totalColonies: number;
referenceDate: string;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
}
export interface NetQuantityDigitRule {
/** Upper limit in grams; `null` = everything above. */
maxGrams: number | null;
minMm: number;
}
export interface ComplianceSettings {
veterinaryOfficeName: string | null;
veterinaryOfficeAddress: string | null;
registrationNumber: string | null;
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
/** MM-TT. */
tskReferenceDay: string;
tskReportDueDay: string | null;
tskMemberNumber: string | null;
tskExcludedColonyTypes: ColonyType[];
netQuantityDigitRules: NetQuantityDigitRule[];
}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import {
createNotice,
fetchNotice,
fetchNotices,
noticeAction,
openNoticePdf,
updateNotice,
} from "./notices-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
describe("notices api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("lists with the kind and year filter only when given", async () => {
await fetchNotices();
await fetchNotices({ kind: "TSK_REPORT", year: 2027 });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices?kind=TSK_REPORT&year=2027");
});
it("reads one notice by encoded id", async () => {
await fetchNotice("a/1");
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/a%2F1");
});
it("creates with the given idempotency key", async () => {
await createNotice({ kind: "REGISTRATION", sentOn: "2026-05-01" }, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/authority-notices");
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({ kind: "REGISTRATION", sentOn: "2026-05-01" }),
headers: { "Idempotency-Key": "key-1" },
});
});
it("posts an action; mark-sent carries date, channel and reference", async () => {
await noticeAction("n1", "recount");
await noticeAction("n1", "mark-sent", { sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices/n1/recount", { method: "POST" });
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices/n1/mark-sent", {
method: "POST",
body: JSON.stringify({ sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" }),
});
});
it("patches the edit", async () => {
await updateNotice("n1", { totalColonies: 9, notes: null });
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/n1", {
method: "PATCH",
body: JSON.stringify({ totalColonies: 9, notes: null }),
});
});
it("downloads the pdf over the authenticated client, never through a link with a token", async () => {
const blob = new Blob(["x"]);
vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "anzeige-n1.pdf" });
await openNoticePdf("n1");
expect(apiFetchBlob).toHaveBeenCalledWith("/authority-notices/n1/pdf", "anzeige-n1.pdf");
expect(downloadBlob).toHaveBeenCalledWith(blob, "anzeige-n1.pdf");
});
});
@@ -0,0 +1,55 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { type CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
AuthorityNotice,
AuthorityNoticeKind,
CreateNoticeInput,
MarkSentInput,
NoticeAction,
NoticeEdit,
} from "./notice-types";
const NOTICES = "/authority-notices";
function noticePath(id: string, rest = ""): string {
return `${NOTICES}/${encodeURIComponent(id)}${rest}`;
}
export function fetchNotices(query: { kind?: AuthorityNoticeKind; year?: number } = {}): Promise<AuthorityNotice[]> {
const params = new URLSearchParams();
if (query.kind) params.set("kind", query.kind);
if (query.year !== undefined) params.set("year", String(query.year));
const text = params.toString();
return apiFetch<AuthorityNotice[]>(text ? `${NOTICES}?${text}` : NOTICES);
}
export function fetchNotice(id: string): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id));
}
/** 409 `DRAFT_EXISTS` carries `existingId`; read it with `draftExists` from errors.ts. */
export function createNotice(input: CreateNoticeInput, options?: CreateOptions): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(NOTICES, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
export function noticeAction(id: string, action: NoticeAction, body?: MarkSentInput): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id, `/${action}`), {
method: "POST",
...(body ? { body: JSON.stringify(body) } : {}),
});
}
export function updateNotice(id: string, input: Partial<NoticeEdit>): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id), { method: "PATCH", body: JSON.stringify(input) });
}
/** The notice as PDF: an authenticated download, never a link that carries a token. */
export async function openNoticePdf(id: string): Promise<void> {
const { blob, filename } = await apiFetchBlob(noticePath(id, "/pdf"), `anzeige-${id}.pdf`);
downloadBlob(blob, filename);
}
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
createRestrictions,
endRestriction,
fetchActiveRestrictions,
fetchRestrictions,
isRestrictionActive,
restrictionsAmong,
type ActiveRestriction,
} from "./restrictions-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
const active = (id: string, apiaryId: string): ActiveRestriction => ({
id,
apiaryId,
apiaryName: `Stand ${apiaryId}`,
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
});
describe("restrictions api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue([]);
});
it("reads the active restrictions and the full list", async () => {
await fetchActiveRestrictions();
await fetchRestrictions();
expect(apiFetch).toHaveBeenNthCalledWith(1, "/apiary-restrictions?active=true");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/apiary-restrictions");
});
it("creates restrictions for several apiaries with one idempotency key", async () => {
const input = { apiaryIds: ["a-1", "a-2"], disease: "Kleiner Beutenkäfer", startsOn: "2026-10-01" };
await createRestrictions(input, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/apiary-restrictions");
expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
expect((init?.headers as Record<string, string>)["Idempotency-Key"]).toBe("key-1");
});
it("ends a restriction by setting endsOn (PATCH, never DELETE)", async () => {
await endRestriction("r/1", "2026-11-30");
expect(apiFetch).toHaveBeenCalledWith("/apiary-restrictions/r%2F1", {
method: "PATCH",
body: JSON.stringify({ endsOn: "2026-11-30" }),
});
});
it("restrictionsAmong keeps only the restrictions of the named apiaries", () => {
const rows = [active("r-1", "a-1"), active("r-2", "a-2"), active("r-3", "a-1")];
expect(restrictionsAmong(["a-1"], rows).map((row) => row.id)).toEqual(["r-1", "r-3"]);
expect(restrictionsAmong([], rows)).toEqual([]);
});
it("treats the start and the end day as inside and an open end as until further notice", () => {
const row = { startsOn: "2026-10-01T00:00:00.000Z", endsOn: "2026-10-05T00:00:00.000Z" };
expect(isRestrictionActive(row, "2026-09-30")).toBe(false);
expect(isRestrictionActive(row, "2026-10-01")).toBe(true);
expect(isRestrictionActive(row, "2026-10-05")).toBe(true);
expect(isRestrictionActive(row, "2026-10-06")).toBe(false);
expect(isRestrictionActive({ ...row, endsOn: null }, "2030-01-01")).toBe(true);
});
});
@@ -0,0 +1,102 @@
import { apiFetch } from "@/lib/auth/api-client";
import { toCalendarDay } from "@/lib/dates";
import { idempotencyHeaders, type CreateOptions } from "@/lib/offline/idempotency";
/** What `GET /apiary-restrictions?active=true` returns: no authority, reference or notes. */
export interface ActiveRestriction {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
}
/**
* A restriction row. Authority, reference, notes and creator come only with
* `compliance:read`; a reader with `apiaries:read` alone gets the rest.
*/
export interface ApiaryRestriction {
id: string;
apiaryId: string;
disease: string;
authority?: string | null;
reference?: string | null;
/** Timestamp of the start day (midnight UTC); read with `toCalendarDay`. */
startsOn: string;
/** Last day of the restriction; `null` = until further notice. */
endsOn: string | null;
notes?: string | null;
}
export interface CreateRestrictionsInput {
apiaryIds: string[];
disease: string;
authority?: string;
reference?: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
endsOn?: string;
notes?: string;
}
export function fetchActiveRestrictions(): Promise<ActiveRestriction[]> {
return apiFetch<ActiveRestriction[]>("/apiary-restrictions?active=true");
}
/** Every restriction, active and ended (ended ones are kept, never deleted). */
export function fetchRestrictions(): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions");
}
/** One row per apiary; the idempotency key makes a retry after a lost answer safe. */
export function createRestrictions(
input: CreateRestrictionsInput,
options: CreateOptions = {},
): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
/** Ending sets the last day; the entry stays. */
export function endRestriction(id: string, endsOn: string): Promise<ApiaryRestriction> {
return apiFetch<ApiaryRestriction>(`/apiary-restrictions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ endsOn }),
});
}
/** The restrictions of the named apiaries, in the order they were given. */
export function restrictionsAmong(
apiaryIds: readonly string[],
active: readonly ActiveRestriction[],
): ActiveRestriction[] {
const wanted = new Set(apiaryIds);
return active.filter((restriction) => wanted.has(restriction.apiaryId));
}
export type RestrictionState = "planned" | "active" | "ended";
/** Not started yet ("Geplant"), in force, or over - by the Berlin calendar day. */
export function restrictionState(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): RestrictionState {
if (toCalendarDay(restriction.startsOn) > day) return "planned";
return isRestrictionActive(restriction, day) ? "active" : "ended";
}
/**
* Active from the start day to the end day, both included; no end day =
* until further notice. `day` is the Berlin calendar day, as on the server.
*/
export function isRestrictionActive(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): boolean {
if (toCalendarDay(restriction.startsOn) > day) return false;
return restriction.endsOn === null || day <= toCalendarDay(restriction.endsOn);
}
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchComplianceSettings, updateComplianceSettings } from "./settings-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
describe("compliance settings api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads the settings", async () => {
await fetchComplianceSettings();
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings");
});
it("patches only what it is given, keeping explicit nulls", async () => {
await updateComplianceSettings({ tskReportDueDay: null, tskReferenceDay: "03-15" });
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings", {
method: "PATCH",
body: JSON.stringify({ tskReportDueDay: null, tskReferenceDay: "03-15" }),
});
});
});
@@ -0,0 +1,12 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { ComplianceSettings } from "./notice-types";
const SETTINGS = "/compliance/settings";
export function fetchComplianceSettings(): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS);
}
export function updateComplianceSettings(input: Partial<ComplianceSettings>): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS, { method: "PATCH", body: JSON.stringify(input) });
}
@@ -0,0 +1,27 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { complianceKey } from "./use-readiness";
import { fetchActiveRestrictions, type ActiveRestriction } from "./restrictions-api";
/** Under the `compliance` key, so one invalidation reloads every compliance query. */
export const activeRestrictionsKey = [...complianceKey, "restrictions", "active"] as const;
const ACTIVE_RESTRICTIONS_STALE_MS = 30_000;
const NONE: ActiveRestriction[] = [];
/**
* The active restrictions, loaded once per page (shared query key). The
* server decides in the end; this only hides a blocked action early.
*/
export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; isLoading: boolean } {
const query = useQuery({
queryKey: activeRestrictionsKey,
queryFn: fetchActiveRestrictions,
retry: false,
// Short-lived: a restriction may start any minute; the server still decides.
staleTime: ACTIVE_RESTRICTIONS_STALE_MS,
refetchOnWindowFocus: true,
});
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
}
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { errorMessage } from "@/lib/forms/error-message";
import { restrictedZone } from "./errors";
import { restrictionsAmong, type ActiveRestriction } from "./restrictions-api";
import { activeRestrictionsKey, useActiveRestrictions } from "./use-active-restrictions";
/** A failed save, worded for the place that shows it. */
export interface ExplainedError {
text: string;
/** A 409: the focus jumps to the message. */
focus: boolean;
/** A 409 "Bitte neu laden": offer to reload the colony. */
reload: boolean;
}
const ZONE_FALLBACK = "Der Stand liegt im Sperrbezirk.";
/**
* Puts the focus on the element (a message with tabIndex -1) when `active`
* turns true - after a 409 the beekeeper must hear what happened.
*/
export function useFocusWhen(active: boolean) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (active) ref.current?.focus();
}, [active]);
return ref;
}
export interface RestrictionGuard {
/** Active restrictions of the apiaries the action touches. */
blocking: ActiveRestriction[];
isBlocked: boolean;
/** Changes with the set of blocking restrictions; use it as a React `key`. */
resetKey: string;
/** The confirmed override reason, only while something blocks. */
overrideReason: string | null;
/** The action may go ahead: nothing blocks, or the override was confirmed. */
canProceed: boolean;
/** Id of the notice, for `aria-describedby` of the switched-off button. */
noticeId: string;
setReason: (reason: string | null) => void;
reset: () => void;
/**
* The text of a failed save: the server wording (409 RESTRICTED_ZONE,
* "Der Stand wurde gerade geändert"), otherwise the fallback. A blocked
* zone the client did not know yet is loaded again.
*/
explain: (error: unknown, fallback: string) => ExplainedError;
/** Loads the active restrictions again (a dialog opens: the list may be old). */
refresh: () => void;
/** The request fragment: the reason only when the override is really used. */
overrideFields: () => { restrictionOverrideReason?: string };
}
/**
* Whether an action that brings a colony into, out of or between apiaries is
* blocked by a restricted zone - and the state of the override. The server
* decides in the end; this hides a blocked action early.
*/
export function useRestrictionGuard(apiaryIds: readonly string[]): RestrictionGuard {
const { restrictions } = useActiveRestrictions();
const noticeId = useId();
const queryClient = useQueryClient();
const [stored, setStored] = useState<{ key: string; reason: string } | null>(null);
const blocking = restrictionsAmong(apiaryIds, restrictions);
const resetKey = blocking.map((restriction) => restriction.id).join(",");
const isBlocked = blocking.length > 0;
// Nothing blocks any more: a reason confirmed earlier belongs to a notice that is gone.
if (!isBlocked && stored !== null) setStored(null);
const overrideReason = isBlocked && stored?.key === resetKey ? stored.reason : null;
return {
blocking,
isBlocked,
resetKey,
overrideReason,
canProceed: !isBlocked || overrideReason !== null,
noticeId,
setReason: (reason) => setStored(reason === null ? null : { key: resetKey, reason }),
reset: () => setStored(null),
explain: (error, fallback) => {
const zone = restrictedZone(error);
if (zone) void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
const conflict = error instanceof ApiError && error.status === 409;
const text = zone ? zone.message || ZONE_FALLBACK : errorMessage(error, fallback);
return { text, focus: conflict, reload: conflict && !zone && /neu laden/i.test(text) };
},
refresh: () => {
void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
},
overrideFields: () => (overrideReason !== null ? { restrictionOverrideReason: overrideReason } : {}),
};
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS } from "./labels";
describe("notification category labels", () => {
it("names the compliance category and offers it in the category selection", () => {
expect(NOTIFICATION_CATEGORY_LABELS.COMPLIANCE).toBe("Behörden & Prüfung");
expect(NOTIFICATION_CATEGORIES).toContain("COMPLIANCE");
});
});
@@ -7,6 +7,7 @@ export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
COMPLIANCE: "Behörden & Prüfung",
};
export const NOTIFICATION_CATEGORIES = Object.keys(
@@ -6,7 +6,8 @@ export type NotificationCategory =
| "SUGGESTION"
| "WITHDRAWAL"
| "SEASON"
| "SYSTEM";
| "SYSTEM"
| "COMPLIANCE";
export interface Notification {
id: string;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { bookingLine } from "./booking-line";
describe("bookingLine", () => {
it("multiplies the dose by the number of colonies", () => {
expect(bookingLine(30, "ML", 6)).toBe("Bucht 30 ml je Volk ab (× 6 = 180 ml).");
});
it("leaves the multiplication out for one colony", () => {
expect(bookingLine(30, "ML", 1)).toBe("Bucht 30 ml je Volk ab.");
expect(bookingLine(30, "ML", 0)).toBe("Bucht 30 ml je Volk ab.");
});
it("writes German decimals and the unit of the medicine", () => {
expect(bookingLine(2.5, "G", 3)).toBe("Bucht 2,5 g je Volk ab (× 3 = 7,5 g).");
expect(bookingLine(1, "PIECE", 2)).toBe("Bucht 1 Stück je Volk ab (× 2 = 2 Stück).");
});
it("does not show float noise", () => {
expect(bookingLine(0.1, "ML", 3)).toBe("Bucht 0,1 ml je Volk ab (× 3 = 0,3 ml).");
});
it("is null without a pack or without a dose", () => {
expect(bookingLine(30, null, 6)).toBeNull();
expect(bookingLine(null, "ML", 6)).toBeNull();
expect(bookingLine(Number.NaN, "ML", 6)).toBeNull();
expect(bookingLine(0, "ML", 6)).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { formatQuantity } from "@/lib/medicines/format";
import type { MedicineUnit } from "@/lib/medicines/types";
/**
* The line above „Speichern“ that tells what the treatment takes out of the
* pack: "Bucht 30 ml je Volk ab (× 6 = 180 ml)." `unit` is `null` when no pack
* is chosen (nothing is booked then), `dose` is `null` while none is typed.
* With one colony the multiplication is left out.
*/
export function bookingLine(
dose: number | null,
unit: MedicineUnit | null,
colonyCount: number,
): string | null {
if (dose === null || unit === null || !Number.isFinite(dose) || dose <= 0) return null;
const perColony = `Bucht ${formatQuantity(dose, unit)} je Volk ab`;
if (colonyCount <= 1) return `${perColony}.`;
return `${perColony} (× ${colonyCount} = ${formatQuantity(dose * colonyCount, unit)}).`;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { doseError, doseText, parsedDose } from "./dose";
describe("doseError", () => {
it("allows an empty dose and valid ones with up to three decimals", () => {
expect(doseError("")).toBeNull();
expect(doseError("2,5")).toBeNull();
expect(doseError("0,001")).toBeNull();
});
it("rejects more than three decimal places in German", () => {
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
});
it("rejects zero and text", () => {
expect(doseError("0")).toBe("Die Dosis muss mindestens 0,001 sein.");
expect(doseError("abc")).toMatch(/Zahl/);
});
});
describe("parsedDose / doseText", () => {
it("round-trips a German decimal", () => {
expect(parsedDose(doseText(2.5))).toBe(2.5);
expect(doseText(null)).toBe("");
});
});
@@ -0,0 +1,29 @@
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
/** The smallest dose the server accepts. */
export const MIN_DOSE = 0.001;
const MAX_DECIMALS = 3;
const DECIMALS = /[.,](\d+)$/;
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
export function doseError(text: string): string | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5.");
const decimals = DECIMALS.exec(text.trim())?.[1].length ?? 0;
if (decimals > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein.";
return null;
}
/** The typed dose as a number, `null` when empty or not a valid number. */
export function parsedDose(text: string): number | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
return Number.isFinite(dose) ? dose : null;
}
/** A stored dose as the user types it: "2.5" is „2,5“. */
export function doseText(dose: number | null): string {
return dose === null ? "" : String(dose).replace(".", ",");
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { medicinePatch, type MedicineGroupForm } from "./edit-patch";
import type { Treatment } from "./types";
const FREE_TEXT: Treatment = {
id: "t-1",
colonyId: "c-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
startDate: null,
endDate: null,
product: "Oxuvar",
activeIngredient: null,
amount: null,
method: null,
reason: null,
batchNumber: "Ch-1",
notes: null,
withdrawalPeriodDays: null,
supplier: "Imkereibedarf",
purchaseProof: null,
prescribingVet: null,
medicineId: null,
medicineLotId: null,
doseQuantity: null,
createdAt: "2026-08-01T00:00:00.000Z",
};
const FROM_PACK: Treatment = {
...FREE_TEXT,
batchNumber: null,
supplier: null,
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 30,
};
function formOf(treatment: Treatment, overrides: Partial<MedicineGroupForm> = {}): MedicineGroupForm {
return {
product: treatment.product,
medicineId: treatment.medicineId ?? "",
medicineLotId: treatment.medicineLotId ?? "",
doseQuantity: treatment.doseQuantity === null ? "" : String(treatment.doseQuantity),
batchNumber: treatment.batchNumber ?? "",
supplier: treatment.supplier ?? "",
purchaseProof: treatment.purchaseProof ?? "",
prescribingVet: treatment.prescribingVet ?? "",
...overrides,
};
}
describe("medicinePatch", () => {
it("is empty while nothing of the group changed", () => {
expect(medicinePatch(FREE_TEXT, formOf(FREE_TEXT))).toEqual({});
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK))).toEqual({});
});
it("clears the lot-sourced texts with null once a pack is in play", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "2,5" }))).toEqual({
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 2.5,
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
it("sends the typed texts and no pack for a catalogue medicine without a pack", () => {
expect(
medicinePatch(FROM_PACK, formOf(FROM_PACK, { medicineLotId: "", supplier: "Apotheke" })),
).toEqual({
medicineId: "m-1",
medicineLotId: null,
doseQuantity: 30,
batchNumber: null,
supplier: "Apotheke",
purchaseProof: null,
prescribingVet: null,
});
});
it("sends an emptied dose as null", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "" })).doseQuantity).toBeNull();
});
it("clears medicine, pack and dose that were stored when a free-text medicine is chosen", () => {
expect(
medicinePatch(
FROM_PACK,
formOf(FROM_PACK, { medicineId: "", medicineLotId: "", product: " Eigenes " }),
),
).toMatchObject({ product: "Eigenes", medicineId: null, medicineLotId: null, doseQuantity: null });
});
it("does not null what a free-text treatment never had", () => {
const patch = medicinePatch(FREE_TEXT, formOf(FREE_TEXT, { supplier: "" }));
expect(patch).toEqual({
product: "Oxuvar",
batchNumber: "Ch-1",
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
});
@@ -0,0 +1,74 @@
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { parsedDose } from "./dose";
import type { Treatment, UpdateTreatmentInput } from "./types";
/** The part of the edit form that decides which medicine, pack and texts a treatment has. */
export interface MedicineGroupForm {
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
batchNumber: string;
supplier: string;
purchaseProof: string;
prescribingVet: string;
}
function sameText(typed: string, stored: string | null): boolean {
return emptyToNull(typed) === stored;
}
function isUnchanged(treatment: Treatment, form: MedicineGroupForm): boolean {
return (
form.product.trim() === treatment.product &&
form.medicineId === (treatment.medicineId ?? "") &&
form.medicineLotId === (treatment.medicineLotId ?? "") &&
parsedDose(form.doseQuantity) === treatment.doseQuantity &&
sameText(form.batchNumber, treatment.batchNumber) &&
sameText(form.supplier, treatment.supplier) &&
sameText(form.purchaseProof, treatment.purchaseProof) &&
sameText(form.prescribingVet, treatment.prescribingVet)
);
}
/** What the pack supplies is cleared on the treatment (the server answers 400 for text next to a pack). */
function textFields(form: MedicineGroupForm) {
if (form.medicineLotId) {
return { batchNumber: null, supplier: null, purchaseProof: null, prescribingVet: null };
}
return {
batchNumber: emptyToNull(form.batchNumber),
supplier: emptyToNull(form.supplier),
purchaseProof: emptyToNull(form.purchaseProof),
prescribingVet: emptyToNull(form.prescribingVet),
};
}
/**
* The medicine part of an update. Left out completely while nothing of it
* changed - the server checks catalogue and pack only when the patch touches
* them, so a note on an old treatment is saved even if its pack was changed
* since. Once something changed the whole group goes out, with `null` for
* everything the user cleared (a stored value must stay removable).
*/
export function medicinePatch(
treatment: Treatment,
form: MedicineGroupForm,
): Partial<UpdateTreatmentInput> {
if (isUnchanged(treatment, form)) return {};
const dose = parsedDose(form.doseQuantity);
const medicine: Partial<UpdateTreatmentInput> = form.medicineId
? {
// The trade name follows the catalogue.
medicineId: form.medicineId,
medicineLotId: form.medicineLotId || null,
doseQuantity: dose,
}
: {
product: form.product.trim(),
...(treatment.medicineId ? { medicineId: null } : {}),
...(treatment.medicineLotId ? { medicineLotId: null } : {}),
...(treatment.medicineId && treatment.doseQuantity !== null ? { doseQuantity: null } : {}),
};
return { ...medicine, ...textFields(form) };
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { lot } from "@/lib/medicines/fixtures.spec-helper";
import { lotLabel, lotSourceLine, lotWarnings } from "./lot-text";
const ML_LOT = lot({
unit: "ML",
batchNumber: "4711",
remaining: 180,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: null,
});
describe("lotLabel", () => {
it("shows batch, rest and expiry", () => {
expect(lotLabel(ML_LOT)).toBe("Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027");
});
it("says so when there is no batch number and leaves out a missing expiry", () => {
expect(lotLabel({ ...ML_LOT, batchNumber: null, expiresOn: null })).toBe(
"Ohne Ch.-B. · Rest 180 ml",
);
});
});
describe("lotSourceLine", () => {
it("lists only what the pack holds", () => {
expect(lotSourceLine(ML_LOT)).toBe(
"aus Packung: Ch.-B. 4711 · Lieferant Tierarzt Muster · Beleg Rechnung 4711",
);
});
it("still says where the data comes from when the pack holds nothing", () => {
expect(
lotSourceLine({ ...ML_LOT, batchNumber: null, supplier: null, purchaseProof: null }),
).toBe("aus Packung");
});
});
describe("lotWarnings", () => {
it("warns about an expired pack, judged on the treatment day", () => {
expect(lotWarnings(ML_LOT, 30, 1, "2027-03-31")).toEqual([]);
expect(lotWarnings(ML_LOT, 30, 1, "2027-04-01")).toEqual([
"Die Packung ist abgelaufen (verw. bis 03/2027).",
]);
});
it("warns when the rest does not cover all colonies", () => {
expect(lotWarnings(ML_LOT, 30, 7, "2026-08-01")).toEqual([
"Die Packung reicht nicht: Rest 180 ml, gebraucht 210 ml.",
]);
expect(lotWarnings(ML_LOT, 30, 6, "2026-08-01")).toEqual([]);
});
it("does not warn about float noise", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0.3 }, 0.1, 3, "2026-08-01")).toEqual([]);
});
it("warns about an empty pack when no dose is typed", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0 }, null, 1, "2026-08-01")).toEqual([
"Die Packung ist leer.",
]);
});
});
@@ -0,0 +1,59 @@
import { formatQuantity, roundToThousandths } from "@/lib/medicines/format";
import type { LotView } from "@/lib/medicines/types";
const SEPARATOR = " · ";
/** "2027-03-31" as "03/2027", read as written. */
function monthYear(day: string): string {
const [year, month] = day.split("-");
return `${month}/${year}`;
}
/** "Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027" */
export function lotLabel(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : "Ohne Ch.-B.",
`Rest ${formatQuantity(lot.remaining, lot.unit)}`,
];
if (lot.expiresOn) parts.push(`verw. bis ${monthYear(lot.expiresOn)}`);
return parts.join(SEPARATOR);
}
/** What the pack supplies instead of the typed fields, e.g. "aus Packung: Ch.-B. 4711 · Lieferant X". */
export function lotSourceLine(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : null,
lot.supplier ? `Lieferant ${lot.supplier}` : null,
lot.purchaseProof ? `Beleg ${lot.purchaseProof}` : null,
lot.prescribingVet ? `Tierarzt ${lot.prescribingVet}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? `aus Packung: ${parts.join(SEPARATOR)}` : "aus Packung";
}
/**
* What speaks against taking from this pack: expired on the treatment day, or
* too little left for the dose of all colonies. Only warnings - the server
* books anyway and the readiness check reports a negative rest.
*/
export function lotWarnings(
lot: LotView,
dose: number | null,
colonyCount: number,
treatmentDay: string,
): string[] {
const warnings: string[] = [];
if (lot.expiresOn && treatmentDay && lot.expiresOn < treatmentDay) {
warnings.push(`Die Packung ist abgelaufen (verw. bis ${monthYear(lot.expiresOn)}).`);
}
if (dose !== null && Number.isFinite(dose) && dose > 0) {
const needed = roundToThousandths(dose * Math.max(colonyCount, 1));
if (needed > lot.remaining) {
warnings.push(
`Die Packung reicht nicht: Rest ${formatQuantity(lot.remaining, lot.unit)}, gebraucht ${formatQuantity(needed, lot.unit)}.`,
);
}
} else if (lot.remaining <= 0) {
warnings.push("Die Packung ist leer.");
}
return warnings;
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { temperatureHint } from "./temperature-hint";
const WINDOW = { min: 12, max: 25 };
describe("temperatureHint", () => {
it("names today and the window of the catalogue", () => {
expect(temperatureHint({ min: 14, max: 22 }, WINDOW)).toEqual({
text: "Heute 14–22 °C. Laut Katalog 12–25 °C.",
warn: false,
});
});
it("warns when today is colder or warmer than the window", () => {
expect(temperatureHint({ min: 9, max: 17 }, WINDOW)).toEqual({
text: "Heute 9–17 °C. Laut Katalog 12–25 °C.",
warn: true,
});
expect(temperatureHint({ min: 20, max: 31 }, WINDOW)?.warn).toBe(true);
});
it("counts the limits themselves as inside", () => {
expect(temperatureHint({ min: 12, max: 25 }, WINDOW)?.warn).toBe(false);
});
it("copes with a window that has only one limit", () => {
expect(temperatureHint({ min: 14, max: 22 }, { min: 12, max: null })).toEqual({
text: "Heute 14–22 °C. Laut Katalog ab 12 °C.",
warn: false,
});
expect(temperatureHint({ min: 14, max: 30 }, { min: null, max: 25 })).toEqual({
text: "Heute 14–30 °C. Laut Katalog bis 25 °C.",
warn: true,
});
});
it("writes German decimals", () => {
expect(temperatureHint({ min: 9.5, max: 17 }, WINDOW)?.text).toBe(
"Heute 9,5–17 °C. Laut Katalog 12–25 °C.",
);
});
it("is null when either side knows no temperature", () => {
expect(temperatureHint({ min: null, max: null }, WINDOW)).toBeNull();
expect(temperatureHint({ min: 9, max: 17 }, { min: null, max: null })).toBeNull();
});
});
@@ -0,0 +1,46 @@
export interface TemperatureRange {
min: number | null;
max: number | null;
}
export interface TemperatureHint {
text: string;
/** Today lies (partly) outside the window of the catalogue. */
warn: boolean;
}
const EN_DASH = "–";
function degrees(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
function rangeText({ min, max }: TemperatureRange): string | null {
if (min !== null && max !== null) return `${degrees(min)}${EN_DASH}${degrees(max)} °C`;
if (min !== null) return `ab ${degrees(min)} °C`;
if (max !== null) return `bis ${degrees(max)} °C`;
return null;
}
function isOutside(today: TemperatureRange, window: TemperatureRange): boolean {
const tooCold = window.min !== null && today.min !== null && today.min < window.min;
const tooWarm = window.max !== null && today.max !== null && today.max > window.max;
return tooCold || tooWarm;
}
/**
* "Heute 9–17 °C. Laut Katalog 12–25 °C." - nothing when either side has no
* temperature at all. The limits themselves are still inside the window.
*/
export function temperatureHint(
today: TemperatureRange,
window: TemperatureRange,
): TemperatureHint | null {
const todayText = rangeText(today);
const windowText = rangeText(window);
if (todayText === null || windowText === null) return null;
return {
text: `Heute ${todayText}. Laut Katalog ${windowText}.`,
warn: isOutside(today, window),
};
}
+16 -1
View File
@@ -16,6 +16,12 @@ export interface Treatment {
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
/** Catalogue entry (veterinary medicine) the treatment was taken from. */
medicineId: string | null;
/** Pack the dose was booked from; supplier, proof, batch and vet then come from it. */
medicineLotId: string | null;
/** Dose per colony in the unit of the medicine. */
doseQuantity: number | null;
createdAt: string;
}
@@ -25,7 +31,12 @@ export interface CreateTreatmentInput {
date: string;
startDate?: string | null;
endDate?: string | null;
product: string;
/** Left out when a catalogue medicine is chosen: the server takes its name. */
product?: string;
medicineId?: string | null;
/** With a pack, supplier / purchaseProof / batchNumber / prescribingVet must be left out (400 otherwise). */
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
@@ -46,6 +57,10 @@ export interface UpdateTreatmentInput {
startDate?: string | null;
endDate?: string | null;
product?: string;
/** `null` clears; with a pack set, `medicineId` must stay set. */
medicineId?: string | null;
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
@@ -0,0 +1,97 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import type { MedicineView } from "@/lib/medicines/types";
import { doseText } from "./dose";
/** The form fields a catalogue medicine can fill in (as typed text). */
export const CATALOGUE_FIELDS = [
"activeIngredient",
"withdrawalPeriodDays",
"method",
"doseQuantity",
] as const;
export type CatalogueField = (typeof CATALOGUE_FIELDS)[number];
/** Fields filled from the catalogue and not touched since: they carry the note „aus Katalog“. */
export type CatalogueFilled = Partial<Record<CatalogueField, true>>;
type CatalogueValues = Record<CatalogueField, string>;
function isCatalogueField(key: string): key is CatalogueField {
return (CATALOGUE_FIELDS as readonly string[]).includes(key);
}
/** What the catalogue knows of the medicine; unknown values (null) are left out. */
export function catalogueValues(medicine: MedicineView): Partial<CatalogueValues> {
const values: Partial<CatalogueValues> = {};
if (medicine.activeIngredient !== null) values.activeIngredient = medicine.activeIngredient;
if (medicine.withdrawalPeriodDays !== null) {
values.withdrawalPeriodDays = String(medicine.withdrawalPeriodDays);
}
if (medicine.applicationMethod !== null) values.method = medicine.applicationMethod;
if (medicine.dosePerColony !== null) values.doseQuantity = doseText(medicine.dosePerColony);
return values;
}
interface CataloguePrefill {
filled: CatalogueFilled;
/**
* Fills the fields the user has not touched from the medicine (`null` =
* „Anderes Mittel“: only takes back what an earlier medicine filled in).
* `markEdited` tells the last-treatment prefill to leave these fields alone,
* so the catalogue wins.
*/
apply: (medicine: MedicineView | null) => void;
/** Call for every edit: the field belongs to the user and is never refilled. */
edited: (key: string) => void;
/** After a draft replaced the values: whatever it holds belongs to the user. */
resetFrom: (values: CatalogueValues) => void;
}
/** Prefills a treatment form from the chosen catalogue medicine. */
export function useCataloguePrefill<T extends CatalogueValues>(
setValues: Dispatch<SetStateAction<T>>,
markEdited: (key: string) => void,
): CataloguePrefill {
const [filled, setFilled] = useState<CatalogueFilled>({});
const [touched, setTouched] = useState<readonly CatalogueField[]>([]);
return {
filled,
apply: (medicine) => {
const next = medicine ? catalogueValues(medicine) : {};
const nextFilled: CatalogueFilled = {};
setValues((current) => {
const updated = { ...current };
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field)) continue;
if (filled[field]) updated[field] = "" as T[CatalogueField];
const value = next[field];
if (value !== undefined) updated[field] = value as T[CatalogueField];
}
return updated;
});
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field) || next[field] === undefined) continue;
nextFilled[field] = true;
markEdited(field);
}
setFilled(nextFilled);
},
edited: (key) => {
if (!isCatalogueField(key)) return;
setTouched((current) => (current.includes(key) ? current : [...current, key]));
setFilled((current) => {
const rest = { ...current };
delete rest[key];
return rest;
});
},
resetFrom: (values) => {
setFilled({});
setTouched(CATALOGUE_FIELDS.filter((field) => values[field].trim() !== ""));
},
};
}
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import type { LotView, MedicineView } from "@/lib/medicines/types";
interface QueryStatus {
isLoading: boolean;
isError: boolean;
isRetrying: boolean;
retry: () => void;
}
export interface MedicineChoice {
/** The user may read the catalogue; without the right nothing is asked for. */
canRead: boolean;
/** The medicines on offer: the active ones, plus the one a treatment already has. */
medicines: readonly MedicineView[] | undefined;
/** Every known medicine, so a chosen one is always named. */
allMedicines: readonly MedicineView[] | undefined;
medicinesStatus: QueryStatus;
medicine: MedicineView | undefined;
/** The selectable lots of the chosen medicine, oldest expiry first. */
lots: readonly LotView[] | undefined;
lotsStatus: QueryStatus;
lot: LotView | undefined;
/** The lot the form chose by itself and nobody has changed since. */
preselectedLotId: string | null;
/** The user made the lot choice (including „Ohne Packung“): never choose again. */
decideLot: () => void;
/** A new medicine was chosen: the oldest lot may be preselected again. */
undecideLot: () => void;
}
interface Options {
canRead: boolean;
medicineId: string;
medicineLotId: string;
/** The pack of a treatment that is being edited; stays on the list when empty. */
includeLotId?: string;
/** The medicine of a treatment that is being edited; offered even when switched off. */
keepMedicineId?: string;
/** A form that edits an existing treatment never chooses a pack by itself. */
startDecided?: boolean;
onPreselect: (lotId: string) => void;
}
function status(query: {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
refetch: () => unknown;
}): QueryStatus {
return {
isLoading: query.isLoading,
isError: query.isError,
isRetrying: query.isFetching,
retry: () => void query.refetch(),
};
}
/**
* The catalogue medicines and the packs of the chosen one for a treatment
* form. A missing right is no error: nothing is asked and only „Anderes
* Mittel“ is offered. The oldest pack is chosen once per medicine until the
* user decides themselves.
*/
export function useMedicineChoice({
canRead,
medicineId,
medicineLotId,
includeLotId,
keepMedicineId,
startDecided = false,
onPreselect,
}: Options): MedicineChoice {
const medicinesQuery = useQuery({
queryKey: ["medicines"],
queryFn: () => fetchMedicines(),
enabled: canRead,
});
const lotsQuery = useQuery({
queryKey: ["medicine-lots", "selectable", medicineId, includeLotId ?? null],
queryFn: () => fetchSelectableLots(medicineId, includeLotId),
enabled: canRead && medicineId !== "",
});
const [decided, setDecided] = useState(startDecided);
const [preselectedLotId, setPreselectedLotId] = useState<string | null>(null);
const [handled, setHandled] = useState<string | null>(null);
const lots = medicineId ? lotsQuery.data : undefined;
const oldestLotId = lots?.[0]?.id;
const preselectKey = oldestLotId ? `${medicineId}:${oldestLotId}` : null;
// Chosen while rendering (like the prefill hooks): the form never shows a
// frame with a medicine and a pack that is still to be picked.
if (!decided && !medicineLotId && oldestLotId && preselectKey !== handled) {
setHandled(preselectKey);
setPreselectedLotId(oldestLotId);
onPreselect(oldestLotId);
}
const allMedicines = canRead ? medicinesQuery.data : undefined;
const medicines = allMedicines?.filter(
(candidate) => candidate.active || candidate.id === keepMedicineId,
);
return {
canRead,
medicines,
allMedicines,
medicinesStatus: status(medicinesQuery),
medicine: allMedicines?.find((candidate) => candidate.id === medicineId),
lots,
lotsStatus: status(lotsQuery),
lot: lots?.find((candidate) => candidate.id === medicineLotId),
preselectedLotId: medicineLotId && medicineLotId === preselectedLotId ? preselectedLotId : null,
decideLot: () => setDecided(true),
undecideLot: () => {
setDecided(false);
setHandled(null);
setPreselectedLotId(null);
},
};
}