diff --git a/ApiCore-Backend/src/compliance/authority-notices.service.spec.ts b/ApiCore-Backend/src/compliance/authority-notices.service.spec.ts
index 42b804b..e232b44 100644
--- a/ApiCore-Backend/src/compliance/authority-notices.service.spec.ts
+++ b/ApiCore-Backend/src/compliance/authority-notices.service.spec.ts
@@ -347,6 +347,44 @@ describe('AuthorityNoticesService', () => {
);
});
+ describe('sent day in the future', () => {
+ const FUTURE = '2999-01-01';
+ const MESSAGE = 'Das Sendedatum darf nicht in der Zukunft liegen.';
+
+ it('create rejects it', async () => {
+ await expect(
+ service.create({ kind: 'REGISTRATION', sentOn: FUTURE }, 'u1'),
+ ).rejects.toMatchObject({ message: MESSAGE });
+ expect(tx.authorityNotice.create).not.toHaveBeenCalled();
+ });
+
+ it('mark-sent rejects it', async () => {
+ await expect(
+ service.markSent('n1', { sentOn: FUTURE }, 'u1'),
+ ).rejects.toMatchObject({ message: MESSAGE });
+ expect(tx.authorityNotice.update).not.toHaveBeenCalled();
+ });
+
+ it('update rejects it', async () => {
+ await expect(
+ service.update('n1', { sentOn: FUTURE }, 'u1'),
+ ).rejects.toMatchObject({ message: MESSAGE });
+ expect(tx.authorityNotice.update).not.toHaveBeenCalled();
+ });
+
+ it('accepts today (Berlin day) and clearing with null stays a different error', async () => {
+ jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
+ try {
+ await service.markSent('n1', { sentOn: '2027-03-02' }, 'u1');
+ await expect(
+ service.markSent('n1', { sentOn: '2027-03-03' }, 'u1'),
+ ).rejects.toMatchObject({ message: MESSAGE });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+ });
+
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
try {
@@ -366,7 +404,7 @@ describe('AuthorityNoticesService', () => {
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
await service.markSent(
'n1',
- { sentOn: '2027-02-02', sentVia: 'E-Mail' },
+ { sentOn: '2026-02-02', sentVia: 'E-Mail' },
'u1',
);
@@ -374,7 +412,7 @@ describe('AuthorityNoticesService', () => {
where: { id: 'n1' },
data: expect.objectContaining({
status: 'SENT',
- sentOn: new Date('2027-02-02T00:00:00.000Z'),
+ sentOn: new Date('2026-02-02T00:00:00.000Z'),
sentVia: 'E-Mail',
openKey: null,
}) as unknown,
@@ -519,7 +557,7 @@ describe('AuthorityNoticesService', () => {
it('a draft has no sent day', async () => {
await expect(
- service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
+ service.update('n1', { sentOn: '2026-01-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -528,7 +566,7 @@ describe('AuthorityNoticesService', () => {
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
);
- await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
+ await service.update('n1', { sentOn: '2026-01-02' }, 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'ComplianceSettings',
diff --git a/ApiCore-Backend/src/compliance/authority-notices.service.ts b/ApiCore-Backend/src/compliance/authority-notices.service.ts
index 21e1ce5..a6a63b2 100644
--- a/ApiCore-Backend/src/compliance/authority-notices.service.ts
+++ b/ApiCore-Backend/src/compliance/authority-notices.service.ts
@@ -86,6 +86,17 @@ function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
}
}
+/** A notice cannot have been sent tomorrow: sentOn must not lie after the Berlin day. */
+function futureSentOnError(
+ sentOn: string | null | undefined,
+): BadRequestException | undefined {
+ return sentOn && sentOn > berlinToday()
+ ? new BadRequestException(
+ 'Das Sendedatum darf nicht in der Zukunft liegen.',
+ )
+ : undefined;
+}
+
@Injectable()
export class AuthorityNoticesService {
constructor(
@@ -178,6 +189,10 @@ export class AuthorityNoticesService {
actingUserId: string,
): Promise
{printDone}
: null} +
{printError}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx
index eda8797..4086a54 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx
@@ -288,7 +288,7 @@ describe("ColonyActions – Sperrbezirk beim Umsetzen", () => {
async function chooseTarget(dialog: HTMLElement, name = "Hausgarten") {
await userEvent.click(within(dialog).getByRole("combobox"));
- await userEvent.click(await screen.findByRole("option", { name }));
+ await userEvent.click(await screen.findByRole("option", { name: new RegExp(name) }));
}
const submit = (dialog: HTMLElement) => within(dialog).getByRole("button", { name: "Umsetzen" });
@@ -309,6 +309,14 @@ describe("ColonyActions – Sperrbezirk beim Umsetzen", () => {
expect(moveColony).toHaveBeenCalledWith("colony-1", { apiaryId: "apiary-2" });
});
+ it("marks apiaries in a restricted zone in the option list with a symbol and the word Sperrbezirk", async () => {
+ vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
+ const dialog = await openMove();
+ await userEvent.click(within(dialog).getByRole("combobox"));
+
+ expect(await screen.findByRole("option", { name: "⚠ Hausgarten (Sperrbezirk)" })).toBeInTheDocument();
+ });
+
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();
@@ -468,7 +476,7 @@ describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => {
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(await screen.findByRole("option", { name: /Hausgarten/ }));
await userEvent.click(within(dialog).getByRole("button", { name: "Umsetzen" }));
return dialog;
}
@@ -502,7 +510,7 @@ describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => {
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(await screen.findByRole("option", { name: /Hausgarten/ }));
await within(dialog).findByText(/liegt im Sperrbezirk/);
const button = within(dialog).getByRole("button", { name: "Umsetzen" });
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx
index e37c3fb..36fd6e8 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx
@@ -38,6 +38,7 @@ 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 { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
function invalidateColony(
@@ -111,6 +112,9 @@ function MoveDialog({ colony }: { colony: Colony }) {
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]);
+ // Marked up front, so nobody finds out only after choosing.
+ const { restrictions } = useActiveRestrictions();
+ const restrictedIds = new Set(restrictions.map((restriction) => restriction.apiaryId));
const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }),
@@ -171,7 +175,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
value={apiaryId}
onChange={setApiaryId}
items={apiaries ? targets : undefined}
- getLabel={(apiary) => apiary.name}
+ getLabel={(apiary) => (restrictedIds.has(apiary.id) ? `⚠ ${apiary.name} (Sperrbezirk)` : apiary.name)}
placeholder="Bienenstand wählen"
/>
)}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx
index 6c8c9b6..f31e59a 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx
@@ -277,5 +277,45 @@ describe("TreatmentEditDialog", () => {
expect(screen.getAllByText("aus Katalog").length).toBeGreaterThan(0);
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
});
+
+ it("clears the old dose and says so when the new medicine has another unit", async () => {
+ await switchToApivar();
+
+ expect(screen.getByLabelText(/Dosis je Volk/)).toHaveValue("");
+ expect(await screen.findByText("Dosis war in ml – bitte neu eintragen.")).toBeInTheDocument();
+ });
+
+ it("keeps the dose when the new medicine has the same unit, and typing removes the note", async () => {
+ const THYMOVAR = medicine({ id: "m-thy", name: "Thymovar", unit: "ML", dosePerColony: null });
+ vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR, THYMOVAR]);
+ 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: "Thymovar" }));
+
+ expect(screen.getByLabelText(/Dosis je Volk/)).toHaveValue("30");
+ expect(screen.queryByText(/bitte neu eintragen/)).not.toBeInTheDocument();
+ });
+
+ it("removes the unit note once a new dose is typed", async () => {
+ await switchToApivar();
+ await screen.findByText(/bitte neu eintragen/);
+
+ await userEvent.type(screen.getByLabelText(/Dosis je Volk/), "2");
+
+ expect(screen.queryByText(/bitte neu eintragen/)).not.toBeInTheDocument();
+ });
+
+ it("rejects a dose with four decimals on save", async () => {
+ await openDialog(FROM_PACK);
+ await screen.findByText(/^aus Packung/);
+ const dose = screen.getByLabelText(/Dosis je Volk/);
+ await userEvent.clear(dose);
+ await userEvent.type(dose, "1,2345");
+
+ expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: /Speichern/ }));
+ expect(updateTreatment).not.toHaveBeenCalled();
+ });
});
});
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
index 907259f..cb77b8a 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
@@ -13,6 +13,7 @@ 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 { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
import type { MedicineView } from "@/lib/medicines/types";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -117,6 +118,8 @@ export function TreatmentEditDialog({
);
// 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).
+ // Set when a switch to a medicine with another unit cleared the dose.
+ const [doseNotice, setDoseNotice] = useState