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 };
|
||||
}
|
||||
Reference in new issue
Block a user