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:

- setSentOn(event.target.value)} /> + setSentOn(event.target.value)} + /> + {dateError ? ( + + ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-steps.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-steps.tsx index 273c6b5..db03700 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-steps.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-steps.tsx @@ -16,14 +16,19 @@ const STEP_WORD: Record = { upcoming: "Danach", }; +/** Id of the heading of step 2, so the page can move the focus here after the PDF download. */ +export const SEND_STEP_ID = "notice-step-send"; + interface StepProps { number: number; title: string; + /** Makes the title focusable (programmatic focus only). */ + titleId?: string; state: StepState; children?: React.ReactNode; } -function Step({ number, title, state, children }: StepProps) { +function Step({ number, title, titleId, state, children }: StepProps) { const Icon = STEP_ICON[state]; return (
  • - {`${number}. ${title}`} + {`${number}. ${title}`} {STEP_WORD[state]} @@ -66,14 +75,14 @@ interface NoticeStepsProps { canManage: boolean; } -/** The way through a draft: open the PDF, send it, confirm here. Symbol plus word per step. */ +/** The way through a draft: download the PDF, send it, confirm here. Symbol plus word per step. */ export function NoticeSteps({ pdfOpened, settings, canManage }: NoticeStepsProps) { return (
      - + Prüf die Angaben im PDF. - + diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.spec.tsx index 674bd03..c071708 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.spec.tsx @@ -54,14 +54,14 @@ describe("NoticeDetailPage", () => { 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 () => { + it("shows three steps: 1. PDF herunterladen, 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[0]).toHaveTextContent("1. PDF herunterladen"); expect(items[1]).toHaveTextContent("2. Ans Amt schicken"); expect(items[2]).toHaveTextContent("3. Hier bestätigen"); // Symbol plus word per step, never colour alone. @@ -72,15 +72,16 @@ describe("NoticeDetailPage", () => { // 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" })); + await userEvent.click(screen.getByRole("button", { name: "PDF herunterladen" })); expect(openNoticePdf).toHaveBeenCalledWith("n1"); - // Now the main action is step 3, it has the focus and a polite message says what to do. + // Now the main action is step 3, the focus is on step 2 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.getByText("2. Ans Amt schicken")).toHaveFocus(); + expect(mark).not.toHaveFocus(); expect(screen.getByRole("status")).toHaveTextContent("PDF heruntergeladen. Schick es jetzt ans Amt."); - expect(screen.queryByRole("button", { name: "PDF öffnen" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "PDF herunterladen" })).not.toBeInTheDocument(); items = steps(); expect(items[0]).toHaveTextContent("Erledigt"); expect(items[1]).toHaveTextContent("Jetzt"); @@ -110,15 +111,51 @@ describe("NoticeDetailPage", () => { } }); + it("limits Gesendet am to today and refuses a future day with a message and focus on the field", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-15T10:00:00Z")); + try { + vi.mocked(fetchNotice).mockResolvedValue(noticeFixture()); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" })); + const field = screen.getByLabelText("Gesendet am"); + expect(field).toHaveAttribute("max", "2026-10-15"); + + fireEvent.change(field, { target: { value: "2026-12-01" } }); + await userEvent.click(screen.getByRole("button", { name: "Bestätigen" })); + + expect(screen.getByText("Das Sendedatum darf nicht in der Zukunft liegen.")).toBeInTheDocument(); + expect(field).toHaveFocus(); + expect(noticeAction).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("announces the sent day after marking as sent and moves the focus to the Versand heading", async () => { + vi.mocked(fetchNotice).mockResolvedValue(noticeFixture()); + const sent = noticeFixture({ status: "SENT", sentOn: "2026-10-15T00:00:00.000Z" }); + vi.mocked(noticeAction).mockImplementation(() => { + vi.mocked(fetchNotice).mockResolvedValue(sent); + return Promise.resolve(sent); + }); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" })); + await userEvent.click(screen.getByRole("button", { name: "Bestätigen" })); + + expect(await screen.findByText("Als gesendet markiert, am 15.10.2026.")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Versand" })).toHaveFocus(); + }); + 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" })); + await userEvent.click(await screen.findByRole("button", { name: "PDF herunterladen" })); 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" })); + await userEvent.click(screen.getByRole("button", { name: "PDF herunterladen" })); expect(await screen.findByText(/Der Server hat einen Fehler gemeldet/)).toBeInTheDocument(); expect(screen.queryByText("Dazu fehlt dir das Recht.")).not.toBeInTheDocument(); }); @@ -145,16 +182,16 @@ describe("NoticeDetailPage", () => { 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: "PDF herunterladen" })); await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" })); - fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2027-01-15" } }); + fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2026-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", + sentOn: "2026-01-15", sentVia: "E-Mail", reference: "AZ 12/27", }); @@ -164,7 +201,7 @@ describe("NoticeDetailPage", () => { 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: "PDF herunterladen" })); await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" })); await userEvent.click(screen.getByRole("button", { name: "Bestätigen" })); @@ -247,6 +284,6 @@ describe("NoticeDetailPage", () => { } expect(screen.getByText("Upload AUTHORITY_NOTICE n1 nur lesen")).toBeInTheDocument(); // Reading the PDF stays possible. - expect(screen.getByRole("button", { name: "PDF öffnen" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "PDF herunterladen" })).toBeInTheDocument(); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.tsx index c680d1f..b501494 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useParams } from "next/navigation"; import { BookOpen, FileText, RotateCcw } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -19,10 +19,10 @@ import { fetchNotice, noticeAction, openNoticePdf } from "@/lib/compliance/notic 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 { MarkSentDialog } from "./mark-sent-dialog"; import { NoticeEditForm } from "./notice-edit-form"; import { NoticeHerd } from "./notice-herd"; -import { NoticeSteps } from "./notice-steps"; +import { NoticeSteps, SEND_STEP_ID } from "./notice-steps"; type SimpleAction = "recount" | "reopen" | "cancel"; @@ -32,6 +32,10 @@ function doneMessage(action: SimpleAction, notice: AuthorityNotice): string { return "Anzeige storniert."; } +function sentMessage(notice: AuthorityNotice): string { + return `Als gesendet markiert, am ${formatDay(noticeDay(notice.sentOn))}.`; +} + function sentText(notice: AuthorityNotice): string { const parts = [`Gesendet am ${formatDay(noticeDay(notice.sentOn))}`]; if (notice.sentVia) parts.push(`über ${notice.sentVia}`); @@ -71,16 +75,24 @@ export default function NoticeDetailPage() { const pdf = useMutation({ mutationFn: () => openNoticePdf(params.id), - onMutate: () => setActionError(null), + onMutate: () => { + setActionError(null); + setMessage(""); + }, onSuccess: () => { setPdfOpened(true); setMessage("PDF heruntergeladen. Schick es jetzt ans Amt."); + // Step 2 is next: the focus goes there, not straight to the mark-sent button. + document.getElementById(SEND_STEP_ID)?.focus(); }, onError: (error) => setActionError(complianceErrorText(error, "Das PDF konnte nicht geladen werden.")), }); const simple = useMutation({ mutationFn: (action: SimpleAction) => noticeAction(params.id, action), - onMutate: () => setActionError(null), + onMutate: () => { + setActionError(null); + setMessage(""); + }, onSuccess: (next, action) => { if (action === "reopen") setPdfOpened(false); applyNotice(next, doneMessage(action, next)); @@ -88,11 +100,6 @@ export default function NoticeDetailPage() { 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 ; if (query.isLoading || query.isError || !notice) { return ( @@ -107,7 +114,7 @@ export default function NoticeDetailPage() { return ( ); } @@ -119,7 +126,7 @@ export default function NoticeDetailPage() { noticeId={notice!.id} disabled={isOffline} describedBy={offlineReason} - onDone={(next) => applyNotice(next, "Als gesendet markiert.")} + onDone={(next) => applyNotice(next, sentMessage(next))} /> ); } @@ -138,7 +145,7 @@ export default function NoticeDetailPage() { primaryAction={primaryAction()} moreActions={ isDraft && pdfOpened - ? [{ label: "PDF nochmal öffnen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }] + ? [{ label: "PDF nochmal herunterladen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }] : undefined } backHref="/compliance/notices" @@ -176,7 +183,7 @@ export default function NoticeDetailPage() { primary={false} disabled={isOffline} describedBy={offlineReason} - onDone={(next) => applyNotice(next, "Als gesendet markiert.")} + onDone={(next) => applyNotice(next, sentMessage(next))} /> )} } /> - + (focusAfterClose.current ? document.getElementById(focusAfterClose.current) : true)} + > Sperrbezirk beenden @@ -101,11 +139,7 @@ export function EndRestrictionDialog({ {({ id, describedBy: fieldDescribedBy }) => ( ({

      ), })); -vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); function row(overrides: Partial): ApiaryRestriction { return { @@ -167,7 +165,56 @@ describe("RestrictionsPage", () => { 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.")); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Die Sperre gilt noch bis einschließlich 03.02.2999."), + ); + await waitFor(() => expect(screen.getByRole("heading", { name: /Aktiv/ })).toHaveFocus()); + }); + + it("reads a planned restriction as ab, an active one as seit", async () => { + vi.mocked(fetchRestrictions).mockResolvedValue([ + row({ id: "r-3", startsOn: "2999-01-01T00:00:00.000Z", endsOn: "2999-01-31T00:00:00.000Z" }), + row({ id: "r-1" }), + ]); + renderPage(); + + const planned = await screen.findByRole("region", { name: /Geplant/ }); + expect(within(planned).getByText("ab 01.01.2999 · bis 31.01.2999")).toBeInTheDocument(); + const active = screen.getByRole("region", { name: /Aktiv/ }); + expect(within(active).getByText(/seit 01\.10\.2020/)).toBeInTheDocument(); + }); + + it("moves the focus to the Beendet heading and announces it after ending", async () => { + vi.mocked(endRestriction).mockImplementation(() => { + vi.mocked(fetchRestrictions).mockResolvedValue([ + row({ id: "r-1", endsOn: "2020-12-01T00:00:00.000Z" }), + row({ id: "r-2", apiaryId: "a-2", endsOn: "2020-11-01T00:00:00.000Z" }), + ]); + return Promise.resolve(row({})); + }); + 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: "2020-12-01" } }); + await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" })); + + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Sperrbezirk beendet. Obstwiese steht jetzt unter „Beendet“."), + ); + await waitFor(() => expect(screen.getByRole("heading", { name: /Beendet/ })).toHaveFocus()); + }); + + it("explains that a restriction that started today ends at the end of today at the earliest", async () => { + const today = new Date().toISOString().slice(0, 10); + vi.mocked(fetchRestrictions).mockResolvedValue([row({ id: "r-1", startsOn: `${today}T00:00:00.000Z` })]); + renderPage(); + const active = await screen.findByRole("region", { name: /Aktiv/ }); + await userEvent.click(within(active).getByRole("button", { name: /Beenden/ })); + const dialog = await screen.findByRole("dialog"); + + expect(within(dialog).queryByText(/trag gestern ein/)).not.toBeInTheDocument(); + expect(within(dialog).getByText(/endet frühestens am Ende von heute/)).toBeInTheDocument(); }); it("keeps the live region for loading and error only", async () => { diff --git a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/page.tsx b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/page.tsx index 4812a2c..0715ef3 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { OctagonAlert } from "lucide-react"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; @@ -15,12 +15,12 @@ import { type ApiaryRestriction, type RestrictionState, } from "@/lib/compliance/restrictions-api"; -import { complianceKey } from "@/lib/compliance/use-readiness"; +import { restrictionListKey } from "@/lib/compliance/use-active-restrictions"; import { berlinCalendarDay } from "@/lib/dates"; import { CreateRestrictionDialog } from "./create-restriction-dialog"; +import { sectionHeadingId, type EndedRestriction } from "./end-restriction-dialog"; import { RestrictionRow } from "./restriction-row"; -const restrictionListKey = [...complianceKey, "restrictions", "list"] as const; const UNKNOWN_APIARY = "Unbekannter Stand"; function Section({ @@ -32,8 +32,9 @@ function Section({ state, canManage, canRead, + onEnded, }: { - id: string; + id: "active" | "planned" | "ended"; title: string; rows: ApiaryRestriction[]; emptyText: string; @@ -41,11 +42,12 @@ function Section({ state: RestrictionState; canManage: boolean; canRead: boolean; + onEnded: (ended: EndedRestriction) => void; }) { const headingId = `restrictions-${id}`; return (
      -

      +

      {`${title} (${rows.length})`}

      {rows.length === 0 ? ( @@ -60,6 +62,7 @@ function Section({ state={state} canManage={canManage} canRead={canRead} + onEnded={onEnded} /> ))} @@ -87,6 +90,18 @@ export default function RestrictionsPage() { const by = (state: RestrictionState) => rows.filter((row) => restrictionState(row, today) === state); return { active: by("active"), planned: by("planned"), ended: by("ended") }; }, [data]); + const [announcement, setAnnouncement] = useState(""); + const [focusRequest, setFocusRequest] = useState<{ section: EndedRestriction["section"]; count: number } | null>(null); + + useEffect(() => { + if (focusRequest) document.getElementById(sectionHeadingId(focusRequest.section))?.focus(); + }, [focusRequest]); + + function handleEnded({ section, message }: EndedRestriction) { + setAnnouncement(message); + setFocusRequest((current) => ({ section, count: (current?.count ?? 0) + 1 })); + } + const nameOf = (apiaryId: string) => names.get(apiaryId) ?? UNKNOWN_APIARY; if (!canRead) return ; @@ -108,6 +123,9 @@ export default function RestrictionsPage() { void query.refetch()} isRetrying={query.isFetching} /> ) : null}
  • +

    + {announcement} +

    {data ? ( <> @@ -120,6 +138,7 @@ export default function RestrictionsPage() { state="active" canManage={canManage} canRead={canRead} + onEnded={handleEnded} /> {planned.length > 0 ? (
    ) : null} {ended.length > 0 ? ( @@ -143,6 +163,7 @@ export default function RestrictionsPage() { state="ended" canManage={canManage} canRead={canRead} + onEnded={handleEnded} /> ) : null} diff --git a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/restriction-row.tsx b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/restriction-row.tsx index f21297b..f6a622f 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/restriction-row.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/restriction-row.tsx @@ -9,7 +9,7 @@ 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"; +import { EndRestrictionDialog, type EndedRestriction } from "./end-restriction-dialog"; interface RestrictionRowProps { restriction: ApiaryRestriction; @@ -17,17 +17,20 @@ interface RestrictionRowProps { state: RestrictionState; canManage: boolean; canRead: boolean; + /** Called after the restriction was ended, with the section it now belongs to. */ + onEnded: (ended: EndedRestriction) => void; } -function periodOf(restriction: ApiaryRestriction): string { - const since = `seit ${formatDay(toCalendarDay(restriction.startsOn))}`; +function periodOf(restriction: ApiaryRestriction, state: RestrictionState): string { + // A planned restriction has not begun: "ab", not "seit". + const since = `${state === "planned" ? "ab" : "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) { +export function RestrictionRow({ restriction, apiaryName, state, canManage, canRead, onEnded }: RestrictionRowProps) { const [showDocuments, setShowDocuments] = useState(false); const details = [restriction.authority, restriction.reference].filter(Boolean).join(" · "); @@ -60,7 +63,7 @@ export function RestrictionRow({ restriction, apiaryName, state, canManage, canR )}

    {restriction.disease}

    -

    {periodOf(restriction)}

    +

    {periodOf(restriction, state)}

    {details ?

    {details}

    : null} {restriction.notes ?

    {restriction.notes}

    : null}
    @@ -76,7 +79,7 @@ export function RestrictionRow({ restriction, apiaryName, state, canManage, canR Verfügung ) : null} - {state !== "ended" && canManage ? : null} + {state !== "ended" && canManage ? : null}
    {canRead && showDocuments ? ( = {}; 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; + const heightOk = isHeightOk(row); 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; + // "1.000" gets the thousands-point hint, like every other number field. + if (!heightOk) problems[index] = decimalMessage(row.minMm, HEIGHT_PROBLEM); + else if (!limitOk) problems[index] = decimalMessage(row.maxGrams, LIMIT_PROBLEM); }); return problems; } +function isHeightOk(row: DigitRuleText): boolean { + const minMm = parseDecimal(row.minMm); + return Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM; +} + +export function digitMaxFieldId(index: number): string { + return `digit-max-${index}`; +} + +export function digitMinFieldId(index: number): string { + return `digit-min-${index}`; +} + +/** Id of the input of the first row with a problem: the height field when the height is wrong, else the limit. */ +export function firstInvalidDigitFieldId( + rows: readonly DigitRuleText[], + problems: Record, +): string | null { + const index = rows.findIndex((_, position) => problems[position] !== undefined); + if (index < 0) return null; + return isHeightOk(rows[index]) ? digitMaxFieldId(index) : digitMinFieldId(index); +} + /** 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) => ({ @@ -99,27 +122,29 @@ export function DigitRulesField({ rows, readOnly, noteId, problems, onChange }: return (
  • -
    - + setRow(index, { minMm: event.target.value })} />
    diff --git a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx index 34323e8..56e0335 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx @@ -171,6 +171,33 @@ describe("ComplianceSettingsPage", () => { expect(updateComplianceSettings).not.toHaveBeenCalled(); }); + it("gives \"1.000\" in Bis (g) the thousands-point hint and moves the focus to that field", async () => { + await openRules(); + const limit = document.getElementById("digit-max-0") as HTMLElement; + await userEvent.clear(limit); + await userEvent.type(limit, "1.000"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(within(limit.closest("li") as HTMLElement).getByRole("alert")).toHaveTextContent( + "Bitte ohne Tausenderpunkt eingeben", + ); + expect(limit).toHaveFocus(); + expect(limit).toHaveAttribute("aria-invalid", "true"); + }); + + it("moves the focus to the height field when the height is the wrong one, even if Erweitert was folded", async () => { + renderPage(); + await screen.findByLabelText("Stichtag (MM-TT)"); + await userEvent.click(screen.getByRole("button", { name: /Erweitert/ })); + const height = document.getElementById("digit-min-0") as HTMLElement; + await userEvent.clear(height); + await userEvent.type(height, "99"); + await userEvent.click(screen.getByRole("button", { name: /Erweitert/ })); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(document.getElementById("digit-min-0")).toHaveFocus(); + }); + it("sends valid rules with the last one unlimited", async () => { vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture()); await openRules(); diff --git a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx index 29f101d..9ce33fd 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { Settings } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; @@ -26,12 +26,30 @@ import { DigitRulesField, type DigitRuleText, digitRuleProblems, + firstInvalidDigitFieldId, digitRulesFromText, digitRulesToText, } from "./digit-rules-field"; const ASSUMPTION = "Annahme – bitte prüfen"; +/** Input id per form field that can carry a validation error, in page order. */ +const FIELD_INPUT_ID: Readonly> = { + tskReferenceDay: "tsk-reference-day", + tskReportDueDay: "tsk-due-day", +}; + +/** The id of the first input with a problem: plain fields come first on the page, then the digit rules. */ +function firstInvalidInputId( + fields: Record, + rules: DigitRuleText[], + ruleProblems: Record, +): string | null { + const fieldId = Object.keys(FIELD_INPUT_ID).find((name) => fields[name] !== undefined); + if (fieldId) return FIELD_INPUT_ID[fieldId]; + return firstInvalidDigitFieldId(rules, ruleProblems); +} + interface FormValues { veterinaryOfficeName: string; veterinaryOfficeAddress: string; @@ -129,8 +147,18 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c const [general, setGeneral] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const [saved, setSaved] = useState(""); + // Counts up per failed save, so the focus moves again even to the same field. + const [focusRequest, setFocusRequest] = useState<{ id: string; count: number } | null>(null); const readOnly = !canManage; + useEffect(() => { + if (focusRequest) document.getElementById(focusRequest.id)?.focus(); + }, [focusRequest]); + + function requestFocus(id: string | null) { + if (id) setFocusRequest((current) => ({ id, count: (current?.count ?? 0) + 1 })); + } + // 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); @@ -162,6 +190,7 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c const { fields } = describeSaveError(error, fallback); setGeneral(Object.keys(fields).length > 0 ? null : complianceErrorText(error, fallback)); setFieldErrors(fields); + requestFocus(firstInvalidInputId(fields, [], {})); }, }); @@ -185,7 +214,10 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c 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 (Object.keys(problems.fields).length > 0 || Object.keys(problems.rules).length > 0) { + requestFocus(firstInvalidInputId(problems.fields, values.rules, problems.rules)); + return; + } if (isOffline) return; mutation.mutate(bodyOf(values)); } @@ -318,7 +350,13 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c - + 0} + >
    Gezählte Völkerarten

    Nur diese Arten zählen in der TSK-Zählung mit.

    diff --git a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx index c1b750b..c2144bb 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx @@ -98,6 +98,27 @@ describe("AppLayout", () => { pushMock.mockClear(); }); + it("starts with a skip link that moves the focus to the page content", async () => { + render( + +
    +

    Seiteninhalt

    +
    +
    , + ); + + const link = screen.getByRole("link", { name: "Zum Inhalt springen" }); + // First in the tab order: before the sidebar and every other link. + await userEvent.tab(); + expect(link).toHaveFocus(); + expect(link).toHaveAttribute("href", "#main-content"); + + await userEvent.keyboard("{Enter}"); + const content = screen.getByText("Seiteninhalt").closest("#main-content"); + expect(content).not.toBeNull(); + expect(content).toHaveFocus(); + }); + it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => { pathname = "/suggestions"; permissions = ["inspections:create"]; diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index e609955..628c593 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -24,6 +24,9 @@ import { BottomNav } from "./bottom-nav"; import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet"; import { PageTransition } from "./page-transition"; +/** Id of the page content, the target of the skip link. */ +const MAIN_CONTENT_ID = "main-content"; + export default function AppLayout({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); @@ -69,6 +72,18 @@ export default function AppLayout({ children }: { children: ReactNode }) { // `position: sticky` and the StickySubmit save bar would never stick. return (
    + { + // The hash alone would not move the focus into a Next.js page reliably. + event.preventDefault(); + pageContentRef.current?.focus(); + pageContentRef.current?.scrollIntoView?.({ block: "start" }); + }} + className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none" + > + Zum Inhalt springen +
    @@ -117,6 +132,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { syncEnabled={sessionMode === "online"} />
    { expect(await screen.findByRole("alert")).toHaveTextContent("Die Menge ist zu klein"); }); - it("marks the negative-rest warning with a symbol, the word Negativ and red text", async () => { + it("marks the negative-rest warning with a symbol, the resulting rest and red text", async () => { renderDialog(); await fill("20", "Verschüttet"); const warning = screen.getByText(/mehr als der Rest/i); expect(warning.closest("span.text-destructive")).not.toBeNull(); - expect(warning).toHaveTextContent("Negativ"); + expect(warning).toHaveTextContent("Mehr als der Rest – der Bestand wird negativ (−8 ml)."); + }); + + it("warns before the reason is filled in, and not for a quantity within the rest", async () => { + renderDialog(); + await fill("20", ""); + expect(screen.getByText(/mehr als der Rest/i)).toBeInTheDocument(); + + await userEvent.clear(screen.getByLabelText(/^Menge/)); + await userEvent.type(screen.getByLabelText(/^Menge/), "12"); + expect(screen.queryByText(/mehr als der Rest/i)).not.toBeInTheDocument(); + }); + + it("labels the reason as Pflicht", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: "Entsorgung" })); + + expect(await screen.findByLabelText(/^Grund/)).toHaveAccessibleName(/Pflicht/); }); it("refuses an ambiguous thousands dot", async () => { diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx index 6adb075..5da0732 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx @@ -5,7 +5,13 @@ import { AlertTriangle, Trash2 } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { bookDisposal } from "@/lib/medicines/api"; -import { decimalMessage, formatQuantity, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format"; +import { + decimalMessage, + formatQuantity, + MEDICINE_UNIT_LABELS, + parseDecimal, + roundToThousandths, +} from "@/lib/medicines/format"; import type { MedicineUnit } from "@/lib/medicines/types"; import { saveErrorText } from "@/lib/honey-batches/errors"; import { useAttemptKey } from "@/lib/medicines/use-attempt-key"; @@ -86,7 +92,9 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) { const problem = problemWith(quantityText, reason); const quantity = parseDecimal(quantityText); - const exceedsRest = problem === null && quantity > remaining; + // Warn as soon as the quantity is valid, even before the reason is filled in. + const exceedsRest = Number.isFinite(quantity) && quantity > remaining; + const restAfter = roundToThousandths(remaining - quantity); const note = problem ?? ""; const mutation = useMutation({ @@ -127,7 +135,10 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) { />
    - + - Negativ: Das ist mehr als der Rest ({formatQuantity(remaining, unit)}). Der Rest wird negativ. + Mehr als der Rest – der Bestand wird negativ ({formatQuantity(restAfter, unit)}). ) : null}

    diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx index e83e588..aedf286 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx @@ -143,6 +143,15 @@ function LotFacts({ lot }: { lot: LotDetailView }) { ); } +/** + * Below `sm` a row becomes a small card (two columns, the reason underneath) + * instead of a table that needs sideways scrolling. + */ +const BOOK_ROW = "max-sm:grid max-sm:grid-cols-2 max-sm:items-baseline max-sm:gap-x-3 max-sm:gap-y-1 max-sm:px-3 max-sm:py-3"; +const BOOK_CELL = "max-sm:p-0 max-sm:whitespace-normal"; +const BOOK_LABELLED = + "max-sm:before:mr-1 max-sm:before:text-xs max-sm:before:font-normal max-sm:before:text-muted-foreground max-sm:before:content-[attr(data-label)_':']"; + function MovementBook({ lot }: { lot: LotDetailView }) { const rows = buildBook(lot); return ( @@ -151,8 +160,8 @@ function MovementBook({ lot }: { lot: LotDetailView }) { Bewegungen
    - - +
    + Datum Art @@ -161,16 +170,23 @@ function MovementBook({ lot }: { lot: LotDetailView }) { Grund - + {rows.map((row) => ( - - {formatDay(row.day)} - {row.label} - {formatSignedQuantity(row.quantity, lot.unit)} - + + {formatDay(row.day)} + {row.label} + + {formatSignedQuantity(row.quantity, lot.unit)} + + - {row.reason ?? "—"} + + {row.reason ?? "—"} + ))} diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx index c3821e2..79b834f 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx @@ -47,6 +47,25 @@ describe("MedicinesPage", () => { expect(within(card).getByText("31.01.2028")).toBeInTheDocument(); }); + it("flags an expired next expiry with symbol, the word abgelaufen and the destructive colour", async () => { + vi.mocked(fetchMedicines).mockResolvedValue([ + medicine({ totalRemaining: 10, nextExpiry: "2020-09-01" }), + ]); + renderPage(); + + const flag = await screen.findByText(/abgelaufen/); + expect(flag).toHaveTextContent("01.09.2020 · abgelaufen"); + expect(flag.closest("span.text-destructive")).not.toBeNull(); + }); + + it("shows a still valid expiry in plain text", async () => { + vi.mocked(fetchMedicines).mockResolvedValue([medicine({ nextExpiry: "2999-01-31" })]); + renderPage(); + + expect(await screen.findByText("31.01.2999")).toBeInTheDocument(); + expect(screen.queryByText(/abgelaufen/)).not.toBeInTheDocument(); + }); + it("shows a negative rest in red with the word Negativ", async () => { vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]); renderPage(); diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx index 1c6d99f..7642d7e 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx @@ -5,7 +5,6 @@ import { Pill } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/lib/auth/auth-context"; import { fetchMedicines } from "@/lib/medicines/api"; -import { formatDay } from "@/lib/medicines/format"; import type { MedicineView } from "@/lib/medicines/types"; import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { EmptyState } from "@/components/empty-state"; @@ -13,6 +12,7 @@ import { EntityCard } from "@/components/entity-card"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; +import { ExpiryText } from "@/components/medicines/expiry-text"; import { RestText } from "@/components/medicines/rest-text"; import { Badge } from "@/components/ui/badge"; import { NoReadAccess } from "@/components/medicines/no-read-access"; @@ -96,7 +96,7 @@ function MedicineGrid({ medicines }: { medicines: MedicineView[] }) { badge={medicine.active ? undefined : Deaktiviert} fields={[ { label: "Rest", value: }, - { label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) }, + { label: "Nächster Verfall", value: }, ]} /> ))} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.spec.tsx index e5fc8b5..8bd4cd3 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.spec.tsx @@ -1,5 +1,5 @@ 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 { SocialMediaTab } from "./social-media-tab"; @@ -137,4 +137,21 @@ describe("SocialMediaTab food business", () => { expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null }); }); + + it("after a successful save the adopt hint is gone and focus is on the confirmation", async () => { + vi.mocked(fetchSiteSettings).mockResolvedValue({ + ...settings, + holderName: "Max Imker", + holderAddress: "Dorfstraße 1, 12345 Ort", + }); + renderTab(); + await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" })); + expect(screen.getByText(/noch nicht gespeichert/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const confirmation = await screen.findByText(/^Gespeichert\./); + expect(screen.queryByText(/noch nicht gespeichert/)).not.toBeInTheDocument(); + await waitFor(() => expect(confirmation).toHaveFocus()); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx index e832792..07aa0e2 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserRound } from "lucide-react"; import { toast } from "sonner"; @@ -28,12 +28,17 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { settings.foodBusinessAddress ?? "", ); const [adoptNote, setAdoptNote] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const savedRef = useRef(null); const [originCountry, setOriginCountry] = useState(settings.originCountry); const [originCountryError, setOriginCountryError] = useState(null); const mutation = useMutation({ mutationFn: updateSiteSettings, onSuccess: () => { + // The "adopted, not saved yet" hint is true no longer. + setAdoptNote(null); + setIsSaved(true); void queryClient.invalidateQueries({ queryKey: ["site-settings"] }); toast.success("Website-Einstellungen gespeichert"); }, @@ -41,6 +46,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")), }); + // After saving, focus moves to the confirmation: the button keeps no focus on a changed page. + useEffect(() => { + if (isSaved) savedRef.current?.focus(); + }, [isSaved]); + // Fills the form fields only; nothing is saved until „Speichern“. function adoptFromHolder() { if (holderName.trim() === "" && holderAddress.trim() === "") { @@ -49,11 +59,13 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { } setFoodBusinessName(holderName); setFoodBusinessAddress(holderAddress); + setIsSaved(false); setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern."); } function handleSubmit(event: FormEvent) { event.preventDefault(); + setIsSaved(false); // NOT NULL in the database: it can be changed, not emptied. if (originCountry.trim().length < 2) { setOriginCountryError("Bitte ein Land angeben."); @@ -76,6 +88,16 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { + {isSaved ? ( +

    + Gespeichert. Die Angaben gelten ab jetzt. +

    + ) : null}

    Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link zu entfernen. diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.spec.tsx new file mode 100644 index 0000000..8444a05 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.spec.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { LabelElement } from "@/lib/label-templates/types"; +import { LabelCanvas } from "./label-canvas"; + +function textElement(overrides: Partial>): LabelElement { + return { + id: "t1", + type: "text", + x: 1, + y: 1, + width: 40, + height: 8, + text: "Honig", + placeholderKey: null, + fontSize: 8, + fontFamily: "sans", + bold: false, + italic: false, + color: "#000000", + align: "left", + ...overrides, + }; +} + +function renderCanvas(elements: LabelElement[]) { + render( + , + ); +} + +describe("LabelCanvas", () => { + it("shows a sample value for a placeholder, not the raw token", () => { + renderCanvas([textElement({ placeholderKey: "jarSize" })]); + + const shown = screen.getByText("500 g"); + expect(shown).toHaveAttribute("data-placeholder", "jarSize"); + expect(shown).toHaveAttribute("title", expect.stringContaining("{{jarSize}}")); + expect(screen.queryByText("{{jarSize}}")).not.toBeInTheDocument(); + }); + + it("shows the typed text of a plain text element", () => { + renderCanvas([textElement({ text: "Honig" })]); + + expect(screen.getByText("Honig")).not.toHaveAttribute("data-placeholder"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx index 3185635..9bb55a9 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx @@ -6,6 +6,7 @@ import { LabelElement, LabelFontFamily, } from "@/lib/label-templates/types"; +import { PLACEHOLDER_SAMPLES } from "@/lib/label-templates/placeholder-keys"; import { cn } from "@/lib/utils"; export const BASE_SCALE = 4; // px per mm at 100% zoom @@ -182,12 +183,17 @@ function boxFor( function renderContent(element: LabelElement, scale: number) { switch (element.type) { case "text": { - const display = element.placeholderKey - ? `{{${element.placeholderKey}}}` - : element.text || "Text"; + // A placeholder shows a sample value (dotted underline, token in the tooltip). + const key = element.placeholderKey; + const display = key ? PLACEHOLDER_SAMPLES[key] : element.text || "Text"; return (

    { expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 })); }); }); + +describe("LabelDesigner compliance panel toggle", () => { + const listeners: (() => void)[] = []; + const query = { + matches: false, + addEventListener: vi.fn((_type: string, listener: () => void) => listeners.push(listener)), + removeEventListener: vi.fn(), + }; + + beforeEach(() => { + listeners.length = 0; + query.matches = false; + vi.mocked(apiFetch).mockReset(); + vi.mocked(apiFetch).mockResolvedValue(RESULT); + vi.stubGlobal("matchMedia", vi.fn(() => query)); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("reopens a panel collapsed on a phone once the window reaches lg", async () => { + const { container } = render(); + const details = container.querySelector("details") as HTMLDetailsElement; + await userEvent.click(screen.getByText("Pflichtangaben prüfen")); + expect(details.open).toBe(false); + + query.matches = true; + act(() => listeners.forEach((listener) => listener())); + + expect(details.open).toBe(true); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx index ca233e9..54de4da 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx @@ -157,8 +157,31 @@ interface LabelDesignerProps { submitLabel: string; } +/** Tailwind `lg`: from here the compliance panel sits in the side column and has no toggle. */ +const LG_QUERY = "(min-width: 64rem)"; + +/** + * Open state of the collapsible compliance panel. The toggle exists only below + * lg, so a panel collapsed on a phone reopens once the window reaches lg + * (rotation, resize); otherwise it would stay hidden with no way back. + */ +function useCompliancePanelOpen(): [boolean, (open: boolean) => void] { + const [open, setOpen] = useState(true); + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const query = window.matchMedia(LG_QUERY); + const reopen = () => { + if (query.matches) setOpen(true); + }; + query.addEventListener("change", reopen); + return () => query.removeEventListener("change", reopen); + }, []); + return [open, setOpen]; +} + export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerProps) { const router = useRouter(); + const [complianceOpen, setComplianceOpen] = useCompliancePanelOpen(); const [name, setName] = useState(initial?.name ?? ""); const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90); const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35); @@ -432,8 +455,168 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP } return ( -
    +
    + + + Elemente +
    + +
    +
    + + + {/* An action menu, not a field: it never holds a value, so the + trigger keeps saying what it does instead of showing the key + just inserted. */} + + + + + {canInsertBlock ? null : ( +

    + Geht, sobald die erste Prüfung da ist. +

    + )} +
    +
    + +
    + + + {Math.round(zoom * 100)}% + + +
    + +
    + +
    + Vorlage @@ -622,196 +805,49 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP - - - Elemente -
    - -
    -
    - - - {/* An action menu, not a field: it never holds a value, so the - trigger keeps saying what it does instead of showing the key - just inserted. */} - - - - - {canInsertBlock ? null : ( -

    - Geht, sobald die erste Prüfung da ist. -

    - )} -
    -
    - - - -
    - - - {Math.round(zoom * 100)}% - - -
    - -
    - -
    -
    - - - Eigenschaften - - - {selectedElement ? ( - changeElement(selectedElement.id, patch)} - onDelete={() => deleteElement(selectedElement.id)} - onDuplicate={() => duplicateElement(selectedElement.id)} - /> - ) : ( -

    - Element im Etikett anklicken, um es zu bearbeiten. Entf/Backspace löscht das - ausgewählte Element – „Rückgängig“ im Hinweis holt es zurück. -

    - )} -
    -
    +
    +
    setComplianceOpen(event.currentTarget.open)} + className="group rounded-xl" + > + + Pflichtangaben prüfen + + + +
    + + + Eigenschaften + + + {selectedElement ? ( + changeElement(selectedElement.id, patch)} + onDelete={() => deleteElement(selectedElement.id)} + onDuplicate={() => duplicateElement(selectedElement.id)} + /> + ) : ( +

    + Element im Etikett anklicken, um es zu bearbeiten. Entf/Backspace löscht das + ausgewählte Element – „Rückgängig“ im Hinweis holt es zurück. +

    + )} +
    +
    +
    ); } diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx index d5185f1..91b84be 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx @@ -883,6 +883,41 @@ describe("NewTreatmentPage", () => { expect(createTreatment).not.toHaveBeenCalled(); }); + it("rejects a dose with four decimals in single and bulk mode before anything is sent", async () => { + renderPage(); + await chooseMedicine("Oxuvar"); + await screen.findByText(SOURCE_LINE); + await userEvent.clear(screen.getByLabelText(/Dosis/)); + await userEvent.type(screen.getByLabelText(/Dosis/), "1,2345"); + + expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument(); + expect(screen.queryByText(/^Bucht/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", SAVE)); + expect(createTreatment).not.toHaveBeenCalled(); + }); + + it("rejects a dose with four decimals in bulk mode", async () => { + renderPage(); + await chooseBulkApiary(); + await chooseMedicine("Oxuvar"); + await screen.findByText(SOURCE_LINE); + await userEvent.clear(screen.getByLabelText(/Dosis/)); + await userEvent.type(screen.getByLabelText(/Dosis/), "1,2345"); + await userEvent.click(screen.getByRole("button", SAVE)); + + expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument(); + expect(createTreatmentsBulk).not.toHaveBeenCalled(); + }); + + it("words the withdrawal hint in the plural when several colonies are chosen", async () => { + renderPage(); + expect(screen.getByText(/aus diesem Volk kein Honig/)).toBeInTheDocument(); + + await chooseBulkApiary(); + + expect(screen.getByText(/aus diesen Völkern kein Honig/)).toBeInTheDocument(); + }); + 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"), diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx index 46e2eba..20ecd65 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx @@ -404,7 +404,7 @@ export default function NewTreatmentPage() { )} 1 ? "diesen Völkern" : "diesem Volk"} kein Honig geerntet werden.`} > {({ id, describedBy }) => ( <> diff --git a/ApiCore-Frontend/src/components/compliance/no-compliance-access.spec.tsx b/ApiCore-Frontend/src/components/compliance/no-compliance-access.spec.tsx new file mode 100644 index 0000000..015df2b --- /dev/null +++ b/ApiCore-Frontend/src/components/compliance/no-compliance-access.spec.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { NoComplianceAccess } from "./no-compliance-access"; + +describe("NoComplianceAccess", () => { + it("names the area in a heading above the missing-right text", () => { + render(); + + expect(screen.getByRole("heading", { level: 1, name: "Prüfbereitschaft" })).toBeInTheDocument(); + expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Zurück zum Start" })).toHaveAttribute("href", "/dashboard"); + }); +}); diff --git a/ApiCore-Frontend/src/components/compliance/no-compliance-access.tsx b/ApiCore-Frontend/src/components/compliance/no-compliance-access.tsx index 8733d52..dcf30ef 100644 --- a/ApiCore-Frontend/src/components/compliance/no-compliance-access.tsx +++ b/ApiCore-Frontend/src/components/compliance/no-compliance-access.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; export function NoComplianceAccess() { return (
    +

    Prüfbereitschaft

    Dazu fehlt dir das Recht.

    diff --git a/ApiCore-Frontend/src/components/form/field.tsx b/ApiCore-Frontend/src/components/form/field.tsx index b5fff32..2eb275a 100644 --- a/ApiCore-Frontend/src/components/form/field.tsx +++ b/ApiCore-Frontend/src/components/form/field.tsx @@ -48,7 +48,7 @@ export function Field({ const Caption = group ? "span" : "label"; return ( -
    +
    {step !== undefined ? ( @@ -45,7 +49,7 @@ export function FormSection({ ); return ( -
    +
    {collapsible ? (
    ); } diff --git a/ApiCore-Frontend/src/components/form/record-select.spec.tsx b/ApiCore-Frontend/src/components/form/record-select.spec.tsx index 8892bec..6caf5ab 100644 --- a/ApiCore-Frontend/src/components/form/record-select.spec.tsx +++ b/ApiCore-Frontend/src/components/form/record-select.spec.tsx @@ -124,4 +124,14 @@ describe("RecordSelect", () => { expect(onChange).toHaveBeenCalledWith(""); expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand"); }); + + it("truncates a long chosen label in the trigger instead of widening the form", () => { + render(); + + const trigger = screen.getByRole("combobox"); + expect(trigger.className).toContain("min-w-0"); + const label = screen.getByText("Obstwiese"); + expect(label.className).toContain("truncate"); + expect(label.className).toContain("min-w-0"); + }); }); diff --git a/ApiCore-Frontend/src/components/form/record-select.tsx b/ApiCore-Frontend/src/components/form/record-select.tsx index 1a76c22..dc1c863 100644 --- a/ApiCore-Frontend/src/components/form/record-select.tsx +++ b/ApiCore-Frontend/src/components/form/record-select.tsx @@ -82,12 +82,15 @@ export function RecordSelect({ id={id} aria-label={ariaLabel} data-field={dataField} - className={cn("h-11 w-full", className)} + className={cn("h-11 w-full min-w-0", className)} aria-describedby={describedBy} aria-invalid={invalid} > - - {triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })} + + {/* A long label is cut with an ellipsis; the list shows it in full. */} + + {triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })} + diff --git a/ApiCore-Frontend/src/components/medicines/expiry-text.tsx b/ApiCore-Frontend/src/components/medicines/expiry-text.tsx new file mode 100644 index 0000000..831743d --- /dev/null +++ b/ApiCore-Frontend/src/components/medicines/expiry-text.tsx @@ -0,0 +1,20 @@ +import { AlertTriangle } from "lucide-react"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { formatDay } from "@/lib/medicines/format"; + +/** + * A "best before" day of a medicine or pack. A day in the past is red AND + * carries a symbol and the word "abgelaufen", so the state does not rely on + * colour alone. The day itself still counts as valid. + */ +export function ExpiryText({ day, today }: { day: string | null; today?: string }) { + if (day && day < (today ?? todayLocalIsoDate())) { + return ( + + + {formatDay(day)} · abgelaufen + + ); + } + return {formatDay(day)}; +} diff --git a/ApiCore-Frontend/src/components/medicines/no-read-access.spec.tsx b/ApiCore-Frontend/src/components/medicines/no-read-access.spec.tsx new file mode 100644 index 0000000..f44010e --- /dev/null +++ b/ApiCore-Frontend/src/components/medicines/no-read-access.spec.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { NoReadAccess } from "./no-read-access"; + +describe("NoReadAccess", () => { + it("names the area in a heading above the missing-right text", () => { + render(); + + expect(screen.getByRole("heading", { level: 1, name: "Tierarzneimittel" })).toBeInTheDocument(); + expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/components/medicines/no-read-access.tsx b/ApiCore-Frontend/src/components/medicines/no-read-access.tsx index f26e1d7..5e8c964 100644 --- a/ApiCore-Frontend/src/components/medicines/no-read-access.tsx +++ b/ApiCore-Frontend/src/components/medicines/no-read-access.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; export function NoReadAccess() { return (
    +

    Tierarzneimittel

    Dazu fehlt dir das Recht.

    diff --git a/ApiCore-Frontend/src/components/treatments/medicine-lot-fields.tsx b/ApiCore-Frontend/src/components/treatments/medicine-lot-fields.tsx index 1311d43..3a11dc1 100644 --- a/ApiCore-Frontend/src/components/treatments/medicine-lot-fields.tsx +++ b/ApiCore-Frontend/src/components/treatments/medicine-lot-fields.tsx @@ -7,7 +7,7 @@ import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format"; import type { LotView, MedicineView } from "@/lib/medicines/types"; import { fetchApiaryWeather } from "@/lib/weather/api"; import { bookingLine } from "@/lib/treatments/booking-line"; -import { parsedDose } from "@/lib/treatments/dose"; +import { doseFormatError, parsedDose } from "@/lib/treatments/dose"; import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text"; import { temperatureHint } from "@/lib/treatments/temperature-hint"; import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill"; @@ -51,6 +51,8 @@ export interface MedicineLotFieldsProps { doseQuantity: string; productError?: string; doseError?: string; + /** A note under the dose, e.g. that it was cleared because the unit changed. */ + doseNotice?: string; /** Which fields carry the note „aus Katalog“. */ filled: CatalogueFilled; onProductChange: (product: string) => void; @@ -75,6 +77,7 @@ export function MedicineLotFields({ doseQuantity, productError, doseError, + doseNotice, filled, onProductChange, onMedicineChange, @@ -148,6 +151,7 @@ export function MedicineLotFields({ hasLot={medicineLotId !== ""} value={doseQuantity} error={doseError} + notice={doseNotice} isFromCatalogue={filled.doseQuantity} onChange={onDoseChange} /> @@ -226,6 +230,7 @@ function DoseField({ hasLot, value, error, + notice, isFromCatalogue, onChange, }: { @@ -234,6 +239,7 @@ function DoseField({ hasLot: boolean; value: string; error: string | undefined; + notice: string | undefined; isFromCatalogue: boolean | undefined; onChange: (dose: string) => void; }) { @@ -242,7 +248,12 @@ function DoseField({ ? "Je Volk. Ohne Dosis wird nichts abgebucht." : "Je Volk, wie auf der Packungsbeilage."; return ( - + {({ id, describedBy, invalid }) => ( <>
    @@ -260,6 +271,11 @@ function DoseField({ {unit ? {unit} : null}
    +
    + {notice ? ( +

    {notice}

    + ) : null} +
    )}
    diff --git a/ApiCore-Frontend/src/lib/auth/api-client.spec.ts b/ApiCore-Frontend/src/lib/auth/api-client.spec.ts index 6fd781a..922158b 100644 --- a/ApiCore-Frontend/src/lib/auth/api-client.spec.ts +++ b/ApiCore-Frontend/src/lib/auth/api-client.spec.ts @@ -44,6 +44,33 @@ describe("api-client", () => { expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]); }); + describe("empty success bodies", () => { + it.each([200, 201, 202])("resolves undefined for a %i with an empty body", async (status) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status }))); + await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined(); + }); + + it("resolves undefined for a 204", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined(); + }); + + it("resolves undefined when Content-Length is 0", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("", { status: 201, headers: { "Content-Length": "0" } })), + ); + await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined(); + }); + + it("still parses a JSON body and still rejects invalid JSON", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(201, { id: "a" }))); + await expect(apiFetch("/x", { method: "POST" })).resolves.toEqual({ id: "a" }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + await expect(apiFetch("/x")).rejects.toBeInstanceOf(SyntaxError); + }); + }); + it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError); diff --git a/ApiCore-Frontend/src/lib/auth/api-client.ts b/ApiCore-Frontend/src/lib/auth/api-client.ts index fb38b0f..60413f9 100644 --- a/ApiCore-Frontend/src/lib/auth/api-client.ts +++ b/ApiCore-Frontend/src/lib/auth/api-client.ts @@ -159,9 +159,23 @@ export async function apiFetch( throw await apiErrorFrom(response); } - if (response.status === 204) { + return parseSuccessBody(response); +} + +/** + * 204, 205 and any 2xx with an empty body (e.g. a 201 from a void controller) + * yield undefined; everything else is parsed as JSON. + */ +async function parseSuccessBody(response: Response): Promise { + if (response.status === 204 || response.status === 205) { return undefined as T; } - - return response.json() as Promise; + if (response.headers.get('Content-Length') === '0') { + return undefined as T; + } + const text = await response.text(); + if (text.trim() === '') { + return undefined as T; + } + return JSON.parse(text) as T; } diff --git a/ApiCore-Frontend/src/lib/compliance/restriction-overlap.spec.ts b/ApiCore-Frontend/src/lib/compliance/restriction-overlap.spec.ts new file mode 100644 index 0000000..4a5746f --- /dev/null +++ b/ApiCore-Frontend/src/lib/compliance/restriction-overlap.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { overlappingRestrictions } from "./restriction-overlap"; +import type { ApiaryRestriction } from "./restrictions-api"; + +function row(overrides: Partial): ApiaryRestriction { + return { + id: "r1", + apiaryId: "a1", + disease: "Amerikanische Faulbrut", + startsOn: "2026-10-01T00:00:00.000Z", + endsOn: null, + ...overrides, + }; +} + +describe("overlappingRestrictions", () => { + it("finds an open-ended restriction at the same apiary", () => { + expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "2026-12-01" })).toHaveLength(1); + }); + + it("ignores other apiaries", () => { + expect(overlappingRestrictions([row({ apiaryId: "a2" })], ["a1"], { startsOn: "2026-12-01" })).toEqual([]); + }); + + it("ignores a restriction that ended before the new start, but counts the end day itself", () => { + const ended = row({ endsOn: "2026-11-30T00:00:00.000Z" }); + expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-12-01" })).toEqual([]); + expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-11-30" })).toHaveLength(1); + }); + + it("ignores a restriction that starts after the new end", () => { + const later = row({ startsOn: "2027-01-01T00:00:00.000Z" }); + expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2026-12-31" })).toEqual([]); + expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2027-01-01" })).toHaveLength(1); + }); + + it("returns nothing without a start day", () => { + expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "" })).toEqual([]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/compliance/restriction-overlap.ts b/ApiCore-Frontend/src/lib/compliance/restriction-overlap.ts new file mode 100644 index 0000000..187c740 --- /dev/null +++ b/ApiCore-Frontend/src/lib/compliance/restriction-overlap.ts @@ -0,0 +1,31 @@ +import { toCalendarDay } from "@/lib/dates"; +import type { ApiaryRestriction } from "./restrictions-api"; + +interface NewPeriod { + /** Calendar day "YYYY-MM-DD". */ + startsOn: string; + /** Calendar day; empty or undefined = until further notice. */ + endsOn?: string; +} + +/** + * The existing restrictions at the chosen apiaries whose period overlaps the + * new one (days are inclusive, no end day = open-ended). Only a hint: the + * server accepts overlapping restrictions. + */ +export function overlappingRestrictions( + existing: readonly ApiaryRestriction[], + apiaryIds: readonly string[], + period: NewPeriod, +): ApiaryRestriction[] { + if (!period.startsOn) return []; + const wanted = new Set(apiaryIds); + const newEnd = period.endsOn ? period.endsOn : null; + return existing.filter((row) => { + if (!wanted.has(row.apiaryId)) return false; + const rowEnd = row.endsOn === null ? null : toCalendarDay(row.endsOn); + const startsBeforeRowEnds = rowEnd === null || period.startsOn <= rowEnd; + const rowStartsBeforeNewEnds = newEnd === null || toCalendarDay(row.startsOn) <= newEnd; + return startsBeforeRowEnds && rowStartsBeforeNewEnds; + }); +} diff --git a/ApiCore-Frontend/src/lib/compliance/use-active-restrictions.ts b/ApiCore-Frontend/src/lib/compliance/use-active-restrictions.ts index a68f2e8..e08a587 100644 --- a/ApiCore-Frontend/src/lib/compliance/use-active-restrictions.ts +++ b/ApiCore-Frontend/src/lib/compliance/use-active-restrictions.ts @@ -25,3 +25,6 @@ export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; is }); return { restrictions: query.data ?? NONE, isLoading: query.isLoading }; } + +/** The full restriction list (all states) of the restrictions page. */ +export const restrictionListKey = [...complianceKey, "restrictions", "list"] as const; diff --git a/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts index 2137241..62fc333 100644 --- a/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts +++ b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts @@ -35,3 +35,23 @@ export const PLACEHOLDER_LABELS: Record = { bestBeforeLine: "Zeile „Mindestens haltbar bis …“", lotLine: "Zeile „L Losnummer“", }; + +/** + * What the designer preview shows for a placeholder instead of the raw + * `{{key}}`: a plausible value, so sizes and line breaks look like the print. + */ +export const PLACEHOLDER_SAMPLES: Record = { + batchNumber: "WH-2026-0001", + honeyType: "Blütenhonig", + lotNumber: "L-0001", + bottlingDate: "15.10.2026", + bestBeforeDate: "10/2028", + jarSize: "500 g", + productName: "Wabenhain Blütenhonig", + legalName: "Blütenhonig", + originCountry: "Herkunft: Deutschland", + foodBusinessName: "Max Mustermann", + foodBusinessAddress: "Musterstr. 1\n86450 Altenmünster", + bestBeforeLine: "Mindestens haltbar bis Ende 10/2028", + lotLine: "L 0001", +}; diff --git a/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts b/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts index 655690d..7b2f4b8 100644 --- a/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts @@ -247,6 +247,9 @@ describe("service worker routes", () => { "/api/v1/label-templates/t-1/print/b-1", "/api/v1/label-templates/t-1/print", "/api/v1/compliance/readiness?x=1", + "/api/v1/site-settings", + "/api/v1/site-settings/", + "/api/v1/Site-Settings/public", ])("never caches %s", (path) => { expect(cachesApi(path)).toBe(false); }); @@ -271,6 +274,17 @@ describe("service worker routes", () => { expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true); }); + it("keeps caching the photo document lists and the bottling and treatment answers offline", () => { + for (const path of [ + "/api/v1/documents?entityType=INSPECTION&entityId=i-1", + "/api/v1/bottling-batches", + "/api/v1/colonies/c-1/treatments", + "/api/v1/treatments/withdrawal-status", + ]) { + expect(cachesApi(path)).toBe(true); + } + }); + it("keeps caching the unrelated routes it cached before", () => { for (const path of [ "/api/v1/tasks", @@ -280,6 +294,7 @@ describe("service worker routes", () => { "/api/v1/label-templates/t-1", "/api/v1/compliances", "/api/v1/veterinary-medicines-overview", + "/api/v1/site-settings-overview", "/api/v1/apiaries/a-1/weather", ]) { expect(cachesApi(path)).toBe(true); diff --git a/ApiCore-Frontend/src/lib/treatments/dose.spec.ts b/ApiCore-Frontend/src/lib/treatments/dose.spec.ts index a5fb6cd..8826fea 100644 --- a/ApiCore-Frontend/src/lib/treatments/dose.spec.ts +++ b/ApiCore-Frontend/src/lib/treatments/dose.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { doseError, doseText, parsedDose } from "./dose"; +import { doseError, doseFormatError, doseText, parsedDose } from "./dose"; describe("doseError", () => { it("allows an empty dose and valid ones with up to three decimals", () => { @@ -11,6 +11,12 @@ describe("doseError", () => { it("rejects more than three decimal places in German", () => { expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen."); expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen."); + expect(doseError("1,2345")).toBe("Höchstens 3 Nachkommastellen."); + expect(doseError("1.2345")).toBe("Höchstens 3 Nachkommastellen."); + }); + + it("names the thousands dot", () => { + expect(doseError("1.000")).toMatch(/Tausenderpunkt/); }); it("rejects zero and text", () => { @@ -19,7 +25,21 @@ describe("doseError", () => { }); }); +describe("doseFormatError", () => { + it("reports only the way the dose is written, so it can show while typing", () => { + expect(doseFormatError("1,2345")).toBe("Höchstens 3 Nachkommastellen."); + expect(doseFormatError("1.000")).toMatch(/Tausenderpunkt/); + expect(doseFormatError("0")).toBeNull(); + expect(doseFormatError("")).toBeNull(); + }); +}); + describe("parsedDose / doseText", () => { + it("never rounds a dose with more than three decimals (single and bulk booking use it)", () => { + expect(parsedDose("1,2345")).toBeNull(); + expect(parsedDose("1,234")).toBe(1.234); + }); + it("round-trips a German decimal", () => { expect(parsedDose(doseText(2.5))).toBe(2.5); expect(doseText(null)).toBe(""); diff --git a/ApiCore-Frontend/src/lib/treatments/dose.ts b/ApiCore-Frontend/src/lib/treatments/dose.ts index a3d603c..0426cf2 100644 --- a/ApiCore-Frontend/src/lib/treatments/dose.ts +++ b/ApiCore-Frontend/src/lib/treatments/dose.ts @@ -1,24 +1,49 @@ -import { decimalMessage, parseDecimal } from "@/lib/medicines/format"; +import { + decimalMessage, + isAmbiguousDecimal, + parseDecimal, + THOUSANDS_SEPARATOR_MESSAGE, +} from "@/lib/medicines/format"; /** The smallest dose the server accepts. */ export const MIN_DOSE = 0.001; const MAX_DECIMALS = 3; const DECIMALS = /[.,](\d+)$/; +function decimalPlaces(text: string): number { + return DECIMALS.exec(text.trim())?.[1].length ?? 0; +} + +/** + * What is wrong with the way the dose is written (thousands dot, too many + * decimals), or `null`. Cheap and final enough to show while typing. + */ +export function doseFormatError(text: string): string | null { + if (text.trim() === "") return null; + if (isAmbiguousDecimal(text)) return THOUSANDS_SEPARATOR_MESSAGE; + if (decimalPlaces(text) > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen."; + return null; +} + /** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */ export function doseError(text: string): string | null { if (text.trim() === "") return null; + const formatError = doseFormatError(text); + if (formatError) return formatError; const dose = parseDecimal(text); if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5."); - const decimals = DECIMALS.exec(text.trim())?.[1].length ?? 0; - if (decimals > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen."; if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein."; return null; } -/** The typed dose as a number, `null` when empty or not a valid number. */ +/** + * The typed dose as a number, `null` when empty or not a valid dose. A dose + * with more than three decimals is never rounded or cut here: it is `null`, + * so no booking line, payload or patch is ever built from it. + */ export function parsedDose(text: string): number | null { if (text.trim() === "") return null; + if (decimalPlaces(text) > MAX_DECIMALS) return null; const dose = parseDecimal(text); return Number.isFinite(dose) ? dose : null; } diff --git a/TASKS.md b/TASKS.md index cb2a10f..4976971 100644 --- a/TASKS.md +++ b/TASKS.md @@ -326,7 +326,7 @@ Plan: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, Annahmen ab 59 in AR - [x] T6a (Tasks 1–13, Backend): Schema und Rechte, Tierarzneimittel mit Packungen und Bewegungsbuch, Dokumente, Etikett-Pflichtangaben mit Druck-Sperre und Probedruck, Sperrbezirk, Behördenpaket (Anzeigen, PDFs), Hinweise und Tagesauswertung, Prüfbereitschaft - [x] T6b (Tasks 14–19, Frontend): Prüfbereitschaft, Tierarzneimittel, Behandlungsmaske mit Mittel/Packung/Dosis, Etikett-Prüfung, Anzeigen und Einstellungen, Sperrbezirk mit Wächtern -- [ ] Browserprüfung aller T6-Seiten (Desktop und 390 px, Fehler- und Leerzustände, Tastatur, `prefers-reduced-motion`) +- [x] Browserprüfung aller T6-Seiten (Desktop und 390 px, Fehler- und Leerzustände, Tastatur, `prefers-reduced-motion`): 0 kritisch, 3 wichtig, 19 klein, alle behoben (2026-10-15). Layout bei 390 px und 1280 px nach den Fixes noch einmal im Browser ansehen. - [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt` prüfen, nach Datenprüfung in Produktion (braucht Freigabe) - [ ] Frage an den Inhaber: „Wer darf Etiketten drucken?“ (heute nur ADMIN, Annahme 77) - [ ] Rechtswerte in `ComplianceSettings` vom Inhaber prüfen lassen (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern; gegen gesetze-im-internet.de und EUR-Lex)