feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
- 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:
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");
|
||||
});
|
||||
});
|
||||
+131
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user