From e712c5d4d14ed3580a9bfd39fbd6dec8259ff724 Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Thu, 15 Oct 2026 18:34:52 +0200
Subject: [PATCH] fix: T6 browser-check findings (compliance, treatments,
labels)
- api-client accepts 2xx answers without body (revoke showed an error)
- authority notices: sentOn in the future is rejected (Berlin day) on
create, mark-sent and PATCH; dialog defaults to the Berlin day
- service worker never caches /api/v1/site-settings
- treatments/new no longer overflows at 390 px (min-w-0 on form
wrappers); dose with more than 3 decimals is an error, never rounded
- medicines: expired lots flagged, disposal over the rest warns
- label designer: side panel at lg, sample values instead of
placeholders, compliance panel reopens on lg, labelled zoom buttons
- skip link to #main-content, headings on no-access pages
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../authority-notices.service.spec.ts | 46 +-
.../compliance/authority-notices.service.ts | 23 +
.../test/authority-notices.e2e-spec.ts | 42 +-
ApiCore-Frontend/public/sw-routes.js | 3 +
ApiCore-Frontend/public/sw.js | 2 +-
.../bottling/[id]/print-labels-card.spec.tsx | 15 +
.../(app)/bottling/[id]/print-labels-card.tsx | 17 +-
.../colonies/[id]/colony-actions.spec.tsx | 14 +-
.../(app)/colonies/[id]/colony-actions.tsx | 6 +-
.../[id]/treatment-edit-dialog.spec.tsx | 40 ++
.../colonies/[id]/treatment-edit-dialog.tsx | 16 +
.../(app)/compliance/acknowledge-dialog.tsx | 2 +-
.../notices/[id]/mark-sent-dialog.tsx | 29 +-
.../compliance/notices/[id]/notice-steps.tsx | 19 +-
.../compliance/notices/[id]/page.spec.tsx | 63 ++-
.../(app)/compliance/notices/[id]/page.tsx | 35 +-
.../src/app/(app)/compliance/page.spec.tsx | 13 +-
.../src/app/(app)/compliance/page.tsx | 16 +-
.../compliance/readiness-item-card.spec.tsx | 34 ++
.../(app)/compliance/readiness-item-card.tsx | 9 +-
.../create-restriction-dialog.spec.tsx | 39 +-
.../create-restriction-dialog.tsx | 33 +-
.../restrictions/end-restriction-dialog.tsx | 56 ++-
.../compliance/restrictions/page.spec.tsx | 53 ++-
.../(app)/compliance/restrictions/page.tsx | 31 +-
.../restrictions/restriction-row.tsx | 15 +-
.../compliance/settings/digit-rules-field.tsx | 47 +-
.../(app)/compliance/settings/page.spec.tsx | 27 ++
.../app/(app)/compliance/settings/page.tsx | 44 +-
.../src/app/(app)/layout.spec.tsx | 21 +
ApiCore-Frontend/src/app/(app)/layout.tsx | 16 +
.../lots/[id]/disposal-dialog.spec.tsx | 21 +-
.../medicines/lots/[id]/disposal-dialog.tsx | 19 +-
.../app/(app)/medicines/lots/[id]/page.tsx | 34 +-
.../src/app/(app)/medicines/page.spec.tsx | 19 +
.../src/app/(app)/medicines/page.tsx | 4 +-
.../automation/social-media-tab.spec.tsx | 19 +-
.../settings/automation/social-media-tab.tsx | 24 +-
.../designer/label-canvas.spec.tsx | 54 +++
.../label-templates/designer/label-canvas.tsx | 14 +-
.../designer/label-designer.spec.tsx | 30 ++
.../designer/label-designer.tsx | 408 ++++++++++--------
.../app/(app)/treatments/new/page.spec.tsx | 35 ++
.../src/app/(app)/treatments/new/page.tsx | 2 +-
.../compliance/no-compliance-access.spec.tsx | 13 +
.../compliance/no-compliance-access.tsx | 1 +
.../src/components/form/field.tsx | 2 +-
.../src/components/form/form-section.tsx | 10 +-
.../components/form/record-select.spec.tsx | 10 +
.../src/components/form/record-select.tsx | 9 +-
.../src/components/medicines/expiry-text.tsx | 20 +
.../medicines/no-read-access.spec.tsx | 12 +
.../components/medicines/no-read-access.tsx | 1 +
.../treatments/medicine-lot-fields.tsx | 20 +-
.../src/lib/auth/api-client.spec.ts | 27 ++
ApiCore-Frontend/src/lib/auth/api-client.ts | 20 +-
.../compliance/restriction-overlap.spec.ts | 40 ++
.../src/lib/compliance/restriction-overlap.ts | 31 ++
.../lib/compliance/use-active-restrictions.ts | 3 +
.../lib/label-templates/placeholder-keys.ts | 20 +
.../src/lib/offline/sw-routes.spec.ts | 15 +
.../src/lib/treatments/dose.spec.ts | 22 +-
ApiCore-Frontend/src/lib/treatments/dose.ts | 33 +-
TASKS.md | 2 +-
64 files changed, 1497 insertions(+), 323 deletions(-)
create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/compliance/no-compliance-access.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/medicines/expiry-text.tsx
create mode 100644 ApiCore-Frontend/src/components/medicines/no-read-access.spec.tsx
create mode 100644 ApiCore-Frontend/src/lib/compliance/restriction-overlap.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/restriction-overlap.ts
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 {
assertYearFits(dto.kind, dto.year);
+ const futureError = futureSentOnError(dto.sentOn);
+ if (futureError) {
+ throw futureError;
+ }
const input = {
kind: dto.kind,
year: dto.year,
@@ -234,6 +249,10 @@ export class AuthorityNoticesService {
dto: MarkSentDto,
actingUserId: string,
): Promise {
+ const futureError = futureSentOnError(dto.sentOn);
+ if (futureError) {
+ return Promise.reject(futureError);
+ }
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
if (before.status !== 'DRAFT') {
return Promise.reject(
@@ -290,6 +309,10 @@ export class AuthorityNoticesService {
): Promise {
// A new sent day can change which notice is the baseline: the mutex
// comes first, in lock order, whenever the sent day is touched.
+ const futureError = futureSentOnError(dto.sentOn);
+ if (futureError) {
+ return Promise.reject(futureError);
+ }
const mutex = dto.sentOn !== undefined;
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
if (before.status === 'CANCELLED') {
diff --git a/ApiCore-Backend/test/authority-notices.e2e-spec.ts b/ApiCore-Backend/test/authority-notices.e2e-spec.ts
index 440e7a0..dd5afab 100644
--- a/ApiCore-Backend/test/authority-notices.e2e-spec.ts
+++ b/ApiCore-Backend/test/authority-notices.e2e-spec.ts
@@ -228,6 +228,42 @@ describe('Authority notices (e2e)', () => {
expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after));
});
+ it('rejects a sent day in the future on create, mark-sent and patch', async () => {
+ const message = 'Das Sendedatum darf nicht in der Zukunft liegen.';
+ const future = '2999-01-01';
+ const create = await request(server())
+ .post('/api/v1/authority-notices')
+ .set(as(beekeeperToken))
+ .send({ kind: 'REGISTRATION', sentOn: future })
+ .expect(400);
+ expect((create.body as { message: string }).message).toBe(message);
+
+ const draft = await request(server())
+ .post('/api/v1/authority-notices')
+ .set(as(beekeeperToken))
+ .send({ kind: 'TSK_REPORT', year: 2031 })
+ .expect(201);
+ const url = `/api/v1/authority-notices/${(draft.body as NoticeBody).id}`;
+ const markSent = await request(server())
+ .post(`${url}/mark-sent`)
+ .set(as(beekeeperToken))
+ .send({ sentOn: future })
+ .expect(400);
+ expect((markSent.body as { message: string }).message).toBe(message);
+ const patch = await request(server())
+ .patch(url)
+ .set(as(beekeeperToken))
+ .send({ sentOn: future })
+ .expect(400);
+ expect((patch.body as { message: string }).message).toBe(message);
+
+ const unchanged = await request(server())
+ .get(url)
+ .set(as(beekeeperToken))
+ .expect(200);
+ expect(unchanged.body).toMatchObject({ status: 'DRAFT', sentOn: null });
+ });
+
it('walks a draft through mark-sent, reopen and cancel', async () => {
const created = await request(server())
.post('/api/v1/authority-notices')
@@ -240,7 +276,7 @@ describe('Authority notices (e2e)', () => {
await request(server())
.post(`${url}/mark-sent`)
.set(as(beekeeperToken))
- .send({ sentOn: '2027-02-01', sentVia: 'E-Mail' })
+ .send({ sentOn: '2026-02-01', sentVia: 'E-Mail' })
.expect(201);
await request(server())
.post(`${url}/recount`)
@@ -291,7 +327,7 @@ describe('Authority notices (e2e)', () => {
const sent = await request(server())
.post('/api/v1/authority-notices')
.set(as(beekeeperToken))
- .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
+ .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' })
.expect(201);
const draft = await request(server())
.post('/api/v1/authority-notices')
@@ -319,7 +355,7 @@ describe('Authority notices (e2e)', () => {
await request(server())
.post('/api/v1/authority-notices')
.set(as(beekeeperToken))
- .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
+ .send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' })
.expect(201);
});
diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js
index 214077d..731b9c2 100644
--- a/ApiCore-Frontend/public/sw-routes.js
+++ b/ApiCore-Frontend/public/sw-routes.js
@@ -79,6 +79,9 @@
/\/api\/v1\/veterinary-medicine-lots(\/|$)/i,
/\/api\/v1\/label-templates\/check(\/|$)/i,
/\/api\/v1\/label-templates\/[^/]+\/(print|check)(\/|$)/i,
+ // the master data (holder and food business name and address): personal
+ // address data, read only with network
+ /\/api\/v1\/site-settings(\/|$)/i,
// One single document (metadata): its kind is not in the path, and it may
// belong to a pack, a notice or a restriction. The lists (with the kind
// in the query, see below) and the photo downloads use other routes.
diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js
index f323d17..5436f16 100644
--- a/ApiCore-Frontend/public/sw.js
+++ b/ApiCore-Frontend/public/sw.js
@@ -21,7 +21,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules.
-const SW_ROUTES_VERSION = "0681ff4b095d";
+const SW_ROUTES_VERSION = "17503174a2a9";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const {
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
index 6bf7dab..2b98cce 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
@@ -200,6 +200,21 @@ describe("PrintLabelsCard", () => {
expect(vi.mocked(downloadBlob).mock.calls[1][1]).not.toMatch(/muster/i);
});
+ it("announces politely that the proof and the real PDF were downloaded", async () => {
+ renderCard();
+ await userEvent.click(await screen.findByRole("button", { name: "Probedruck (MUSTER)" }));
+
+ const status = await screen.findByText(/Probedruck \(MUSTER\) heruntergeladen/);
+ expect(status.closest('[role="status"]')).toHaveAttribute("aria-live", "polite");
+
+ const main = screen.getByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+ await userEvent.click(main);
+
+ expect(await screen.findByText("Etiketten-PDF heruntergeladen.")).toBeInTheDocument();
+ expect(screen.queryByText(/Probedruck \(MUSTER\) heruntergeladen/)).not.toBeInTheDocument();
+ });
+
it("sends no second request while the first one is pending", async () => {
let finish: (blob: Blob) => void = () => {};
vi.mocked(printLabels).mockReturnValue(new Promise((resolve) => (finish = resolve)));
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
index e3ccb2d..4ed2cf5 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
@@ -22,6 +22,8 @@ import { Label } from "@/components/ui/label";
import { RecordSelect } from "@/components/form/record-select";
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
+const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
+const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
const LINK_CLASS =
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
@@ -140,6 +142,7 @@ export function PrintLabelsCard({
const [startPosition, setStartPosition] = useState(1);
const [serverGaps, setServerGaps] = useState(null);
const [printError, setPrintError] = useState(null);
+ const [printDone, setPrintDone] = useState(null);
const gapsRef = useRef(null);
const focusGapsNext = useRef(false);
@@ -183,14 +186,17 @@ export function PrintLabelsCard({
}, [serverGaps]);
const mutation = useMutation({
- mutationFn: (proof: boolean) =>
- printLabels(effectiveTemplateId, bottlingBatchId, {
+ mutationFn: (proof: boolean) => {
+ setPrintDone(null);
+ return printLabels(effectiveTemplateId, bottlingBatchId, {
startPosition: startPosition > 1 ? startPosition : undefined,
proof,
- }),
+ });
+ },
onSuccess: (blob, proof) => {
setServerGaps(null);
setPrintError(null);
+ setPrintDone(proof ? PROOF_DONE : PRINT_DONE);
downloadBlob(blob, proof ? "etiketten-muster.pdf" : "etiketten.pdf");
},
onError: (cause) => {
@@ -198,10 +204,12 @@ export function PrintLabelsCard({
if (missing) {
// The server knows better than the last check: show its list and go there.
setPrintError(null);
+ setPrintDone(null);
focusGapsNext.current = true;
setServerGaps({ templateId: effectiveTemplateId, at: Date.now(), gaps: missing.gaps });
return;
}
+ setPrintDone(null);
setPrintError(labelCheckErrorText(cause, PRINT_FALLBACK));
},
});
@@ -355,6 +363,9 @@ export function PrintLabelsCard({
) : null}
+
+ {printDone ?
{printDone}
: null}
+
{printError ? (
{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(undefined);
const [staleWithdrawal, setStaleWithdrawal] = useState(undefined);
const [catalogueReady, setCatalogueReady] = useState(false);
if (open && !catalogueReady) {
@@ -139,6 +142,17 @@ export function TreatmentEditDialog({
setProduct(medicine ? medicine.name : medicineId ? "" : product);
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
catalogue.apply(medicine);
+ // A number is only meaningful in its unit: never read the old dose in the new one.
+ // (After `apply`, whose snapshot would otherwise write the old dose back. A dose that
+ // the catalogue filled was replaced by `apply` itself.)
+ const oldUnit = choice.medicine?.unit;
+ const unitChanged = hadMedicine && medicine !== null && oldUnit !== undefined && oldUnit !== medicine.unit;
+ if (unitChanged && doseQuantity.trim() !== "" && !catalogue.filled.doseQuantity) {
+ setDoseQuantity("");
+ setDoseNotice(`Dosis war in ${MEDICINE_UNIT_LABELS[oldUnit]} – bitte neu eintragen.`);
+ } else {
+ setDoseNotice(undefined);
+ }
const isStale = hadMedicine && oldWithdrawal !== "" && oldWithdrawal !== known;
setStaleWithdrawal(isStale ? (known ?? null) : undefined);
}
@@ -250,6 +264,7 @@ export function TreatmentEditDialog({
doseQuantity={doseQuantity}
productError={fieldErrors.product}
doseError={fieldErrors.doseQuantity}
+ doseNotice={doseNotice}
filled={catalogue.filled}
onProductChange={(next) => {
setProduct(next);
@@ -259,6 +274,7 @@ export function TreatmentEditDialog({
onLotChange={chooseLot}
onDoseChange={(next) => {
catalogue.edited("doseQuantity");
+ setDoseNotice(undefined);
setDoseQuantity(next);
setFieldErrors((current) => ({ ...current, doseQuantity: undefined }));
}}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
index 9efa928..37706a7 100644
--- a/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
@@ -109,7 +109,7 @@ function AcknowledgeDialog({ item, disabled, describedBy, onDone }: AcknowledgeD
Punkt quittieren
- {item.title}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen.
+ {item.title.replace(/[.!?]+$/, "")}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen.
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx
index e3cd86b..b8781af 100644
--- a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { FormEvent, useState } from "react";
+import { FormEvent, useRef, useState } from "react";
import { Send } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
@@ -28,6 +28,8 @@ function todayInput(): string {
return berlinCalendarDay(new Date().toISOString());
}
+const FUTURE_SENT_ON_ERROR = "Das Sendedatum darf nicht in der Zukunft liegen.";
+
/** 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";
@@ -72,6 +74,9 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
const [sentVia, setSentVia] = useState("");
const [reference, setReference] = useState("");
const [error, setError] = useState(null);
+ const [dateError, setDateError] = useState(null);
+ const sentOnRef = useRef(null);
+ const today = todayInput();
const mutation = useMutation({
mutationFn: () => {
@@ -90,6 +95,12 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
+ if (sentOn > today) {
+ setDateError(FUTURE_SENT_ON_ERROR);
+ sentOnRef.current?.focus();
+ return;
+ }
+ setDateError(null);
if (isOffline) return;
mutation.mutate();
}
@@ -98,7 +109,21 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice: