fix: T6 browser-check findings (compliance, treatments, labels)
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 14m30s

- api-client accepts 2xx answers without body (revoke showed an error)
- authority notices: sentOn in the future is rejected (Berlin day) on
  create, mark-sent and PATCH; dialog defaults to the Berlin day
- service worker never caches /api/v1/site-settings
- treatments/new no longer overflows at 390 px (min-w-0 on form
  wrappers); dose with more than 3 decimals is an error, never rounded
- medicines: expired lots flagged, disposal over the rest warns
- label designer: side panel at lg, sample values instead of
  placeholders, compliance panel reopens on lg, labelled zoom buttons
- skip link to #main-content, headings on no-access pages

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 18:34:52 +02:00
1 parent 6c390efe7b
commit e712c5d4d1
64 files changed
+1497 -323

No files matched your search

@@ -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',
@@ -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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
// 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') {
@@ -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);
});
+3
View File
@@ -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.
+1 -1
View File
@@ -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 {
@@ -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<Blob>((resolve) => (finish = resolve)));
@@ -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<ServerGaps | null>(null);
const [printError, setPrintError] = useState<string | null>(null);
const [printDone, setPrintDone] = useState<string | null>(null);
const gapsRef = useRef<HTMLElement>(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({
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<div role="status" aria-live="polite" className="empty:hidden">
{printDone ? <p className="text-sm font-medium">{printDone}</p> : null}
</div>
{printError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{printError}
@@ -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" });
@@ -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"
/>
)}
@@ -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();
});
});
});
@@ -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<string | undefined>(undefined);
const [staleWithdrawal, setStaleWithdrawal] = useState<string | null | undefined>(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 }));
}}
@@ -109,7 +109,7 @@ function AcknowledgeDialog({ item, disabled, describedBy, onDone }: AcknowledgeD
<DialogHeader>
<DialogTitle>Punkt quittieren</DialogTitle>
<DialogDescription>
{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.
</DialogDescription>
</DialogHeader>
<Field label="Notiz" required hint={`Mindestens ${MIN_NOTE_LENGTH} Zeichen.`} error={noteError}>
@@ -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<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const sentOnRef = useRef<HTMLInputElement>(null);
const today = todayInput();
const mutation = useMutation({
mutationFn: () => {
@@ -90,6 +95,12 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
function handleSubmit(event: FormEvent<HTMLFormElement>) {
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:
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-on">Gesendet am</Label>
<Input id="sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
<Input
id="sent-on"
ref={sentOnRef}
type="date"
value={sentOn}
max={today}
aria-invalid={dateError ? true : undefined}
aria-describedby={dateError ? "sent-on-error" : undefined}
onChange={(event) => setSentOn(event.target.value)}
/>
{dateError ? (
<p id="sent-on-error" role="alert" className="text-sm text-destructive">
{dateError}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-via">Gesendet über</Label>
@@ -16,14 +16,19 @@ const STEP_WORD: Record<StepState, string> = {
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 (
<li
@@ -31,7 +36,11 @@ function Step({ number, title, state, children }: StepProps) {
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{`${number}. ${title}`}</span>
<span
id={titleId}
tabIndex={titleId ? -1 : undefined}
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>{`${number}. ${title}`}</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
<Icon className="size-4" aria-hidden />
{STEP_WORD[state]}
@@ -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 (
<ol aria-label="Schritte" className="flex flex-col gap-2">
<Step number={1} title="PDF öffnen" state={pdfOpened ? "done" : "current"}>
<Step number={1} title="PDF herunterladen" state={pdfOpened ? "done" : "current"}>
Prüf die Angaben im PDF.
</Step>
<Step number={2} title="Ans Amt schicken" state={pdfOpened ? "current" : "upcoming"}>
<Step number={2} title="Ans Amt schicken" titleId={SEND_STEP_ID} state={pdfOpened ? "current" : "upcoming"}>
<OfficeText settings={settings} canManage={canManage} />
</Step>
<Step number={3} title="Hier bestätigen" state="upcoming">
@@ -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();
});
});
@@ -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 <NoComplianceAccess />;
if (query.isLoading || query.isError || !notice) {
return (
@@ -107,7 +114,7 @@ export default function NoticeDetailPage() {
return (
<Button size="touch" disabled={isOffline || pdf.isPending} aria-describedby={offlineReason} onClick={() => pdf.mutate()}>
<FileText aria-hidden />
PDF öffnen
PDF herunterladen
</Button>
);
}
@@ -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))}
/>
)}
<Button
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CompliancePage from "./page";
@@ -176,4 +176,15 @@ describe("CompliancePage", () => {
expect(main).toHaveAttribute("href", "#item-unknown-rule-global");
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
});
it("announces Bericht heruntergeladen after the report PDF", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(reportFixture([open({ key: "a" })]));
vi.mocked(downloadReadinessReport).mockResolvedValue(undefined);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Mehr" }));
await userEvent.click(await screen.findByRole("menuitem", { name: /Bericht \(PDF\)/ }));
await waitFor(() => expect(screen.getByText("Bericht heruntergeladen.")).toBeInTheDocument());
});
});
@@ -76,16 +76,25 @@ export default function CompliancePage() {
const query = useReadiness();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [actionError, setActionError] = useState<string | null>(null);
const [actionDone, setActionDone] = useState("");
const year = new Date().getFullYear();
const treatmentLog = useMutation({
mutationFn: () => exportTreatmentLogPdf(year),
onMutate: () => setActionError(null),
onMutate: () => {
setActionError(null);
setActionDone("");
},
onSuccess: () => setActionDone("Bestandsbuch heruntergeladen."),
onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
});
const report = useMutation({
mutationFn: downloadReadinessReport,
onMutate: () => setActionError(null),
onMutate: () => {
setActionError(null);
setActionDone("");
},
onSuccess: () => setActionDone("Bericht heruntergeladen."),
onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
});
@@ -166,6 +175,9 @@ export default function CompliancePage() {
<div aria-live="assertive">
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
</div>
<p role="status" className="text-sm text-muted-foreground empty:hidden">
{actionDone}
</p>
<div aria-live="polite" className="flex flex-col gap-6">
{query.isLoading ? <LoadingState what="Die Prüfpunkte" /> : null}
{!query.isLoading && data === undefined ? (
@@ -92,4 +92,38 @@ describe("ReadinessItemCard", () => {
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute("href", "/compliance/settings");
});
it("shows Beheben directly when every subject leads to the same page", () => {
renderCard(
itemFixture({
key: "holder-data",
subjects: [{ type: "SITE_SETTINGS", id: "singleton", label: "Stammdaten" }],
}),
);
const direct = screen.getAllByRole("link", { name: /Beheben/, hidden: true }).filter((link) => !link.closest("details"));
expect(direct).toHaveLength(1);
expect(direct[0]).toHaveAttribute("href", "/settings/automation?tab=social-media");
});
it("keeps Beheben inside the disclosure when the target depends on the subject", () => {
renderCard(
itemFixture({
key: "lot-expired",
subjects: [{ type: "lot", id: "l1", label: "Charge A" }],
}),
);
const links = screen.getAllByRole("link", { name: /Beheben/ });
expect(links).toHaveLength(1);
expect(links[0].closest("details")).not.toBeNull();
});
it("writes the acknowledge dialog text without a double period", async () => {
renderCard(itemFixture({ acknowledgeable: true, title: "Eine Packung hat noch Bestand." }));
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
expect(await screen.findByText(/Eine Packung hat noch Bestand\. Schreib kurz/)).toBeInTheDocument();
});
});
@@ -61,6 +61,13 @@ function SubjectList({ item }: { item: ReadinessItem }) {
);
}
/** True when every affected subject leads to the same page as the rule itself (no subject needed to choose it). */
function hasSubjectFreeFix(item: ReadinessItem): boolean {
if (item.subjects.length === 0) return true;
const rule = fixLinkFor(item.key, null);
return item.subjects.every((subject) => fixLinkFor(item.key, subject).href === rule.href);
}
/** Anchor of a card, so the main action can point at an item whose page is not there yet. */
export function cardId(item: Pick<ReadinessItem, "key" | "subjectId">): string {
return `item-${item.key}-${item.subjectId}`;
@@ -124,7 +131,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
) : null}
{isOpen ? <SubjectList item={item} /> : null}
<div className="flex flex-wrap items-center gap-2">
{isOpen && item.subjects.length === 0 ? (
{isOpen && hasSubjectFreeFix(item) ? (
<FixLink itemKey={item.key} subject={null} context={item.title} />
) : null}
{canManage ? <AcknowledgementActions item={item} onDone={handleDone} /> : null}
@@ -3,7 +3,7 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { createRestrictions } from "@/lib/compliance/restrictions-api";
import { createRestrictions, fetchRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
import { CreateRestrictionDialog } from "./create-restriction-dialog";
@@ -11,6 +11,7 @@ vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
createRestrictions: vi.fn(),
fetchRestrictions: vi.fn(),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -37,6 +38,42 @@ describe("CreateRestrictionDialog", () => {
] as never);
vi.mocked(createRestrictions).mockReset();
vi.mocked(createRestrictions).mockResolvedValue([]);
vi.mocked(fetchRestrictions).mockResolvedValue([]);
});
it("hints at an overlapping restriction at the same apiary, but does not block", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([
{
id: "r-old",
apiaryId: "a-1",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01T00:00:00.000Z",
endsOn: null,
},
]);
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-12-01");
const hint = await within(dialog).findByRole("status");
expect(hint).toHaveTextContent("Obstwiese: Amerikanische Faulbrut, 01.10.2026 bis auf Weiteres");
expect(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" })).toBeEnabled();
});
it("shows no overlap hint for another apiary", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([
{ id: "r-old", apiaryId: "a-1", disease: "Amerikanische Faulbrut", startsOn: "2026-10-01T00:00:00.000Z", endsOn: null },
]);
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Hausgarten" }));
await waitFor(() => expect(fetchRestrictions).toHaveBeenCalled());
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
});
it("offers the disease as a ChoiceGroup with free text only for Andere", async () => {
@@ -5,12 +5,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiaries } from "@/lib/apiaries/api";
import { complianceErrorText } from "@/lib/compliance/errors";
import { overlappingRestrictions } from "@/lib/compliance/restriction-overlap";
import {
createRestrictions,
fetchRestrictions,
type CreateRestrictionsInput,
} from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay } from "@/lib/dates";
import { restrictionListKey } from "@/lib/compliance/use-active-restrictions";
import { berlinCalendarDay, toCalendarDay } from "@/lib/dates";
import { formatDay } from "@/lib/medicines/format";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
@@ -125,6 +129,18 @@ export function CreateRestrictionDialog() {
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const apiaries = apiariesQuery.data ?? [];
// Only for the overlap hint: a failed load just means no hint.
const restrictionsQuery = useQuery({
queryKey: restrictionListKey,
queryFn: fetchRestrictions,
enabled: open,
retry: false,
});
const overlaps = overlappingRestrictions(restrictionsQuery.data ?? [], values.apiaryIds, {
startsOn: values.startsOn,
endsOn: values.endsOn,
});
const apiaryNames = new Map(apiaries.map((apiary) => [apiary.id, apiary.name]));
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
@@ -255,6 +271,21 @@ export function CreateRestrictionDialog() {
/>
)}
</Field>
{overlaps.length > 0 ? (
<div role="status" className="rounded-lg border border-border bg-muted/50 p-3 text-sm">
<p className="font-medium">Für diesen Zeitraum gibt es schon einen Eintrag:</p>
<ul className="mt-1 list-disc pl-5">
{overlaps.map((row) => (
<li key={row.id}>
{`${apiaryNames.get(row.apiaryId) ?? "Unbekannter Stand"}: ${row.disease}, ${formatDay(toCalendarDay(row.startsOn))} bis ${
row.endsOn === null ? "auf Weiteres" : formatDay(toCalendarDay(row.endsOn))
}`}
</li>
))}
</ul>
<p className="mt-1 text-muted-foreground">Du kannst trotzdem eintragen.</p>
</div>
) : null}
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={3}>
<Field label="Behörde">
{({ id }) => (
@@ -1,8 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { formatDay } from "@/lib/medicines/format";
import { complianceErrorText } from "@/lib/compliance/errors";
import { endRestriction, type ApiaryRestriction } from "@/lib/compliance/restrictions-api";
@@ -39,13 +38,40 @@ function stillHoldsText(endsOn: string): string {
return `Die Sperre gilt noch bis einschließlich ${formatDay(endsOn)}.`;
}
export type RestrictionSection = "active" | "ended";
export interface EndedRestriction {
/** The list heading the focus belongs on: where the restriction is listed now. */
section: RestrictionSection;
/** What a screen reader hears. */
message: string;
}
/** Id of the heading of a section on the restrictions page. */
export function sectionHeadingId(section: RestrictionSection): string {
return `restrictions-${section}`;
}
/** A restriction that starts today cannot end "yesterday": say when it can end instead. */
function endDayHint(startsTodayOrLater: boolean, stillHolds: boolean, endsOn: string): string {
if (startsTodayOrLater) {
const earliest = "Die Sperre beginnt heute oder später und endet frühestens am Ende von heute.";
return stillHolds ? `${earliest} ${stillHoldsText(endsOn)}` : earliest;
}
return stillHolds
? stillHoldsText(endsOn)
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein.";
}
/** Ending sets the last day; the entry stays in the list as "Beendet". */
export function EndRestrictionDialog({
restriction,
apiaryName,
onEnded,
}: {
restriction: ApiaryRestriction;
apiaryName: string;
onEnded: (ended: EndedRestriction) => void;
}) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
@@ -55,13 +81,23 @@ export function EndRestrictionDialog({
const [error, setError] = useState<string | null>(null);
const today = berlinCalendarDay(new Date().toISOString());
const stillHolds = endsOn !== "" && endsOn >= today;
const startsTodayOrLater = startsOn >= today;
const focusAfterClose = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: () => endRestriction(restriction.id, endsOn),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: complianceKey });
toast.success(stillHolds ? stillHoldsText(endsOn) : "Sperrbezirk beendet");
onSuccess: async () => {
const section: RestrictionSection = stillHolds ? "active" : "ended";
focusAfterClose.current = sectionHeadingId(section);
await queryClient.invalidateQueries({ queryKey: complianceKey });
setOpen(false);
// The row may be gone with the refetch: the page moves the focus, not this dialog.
onEnded({
section,
message: stillHolds
? stillHoldsText(endsOn)
: `Sperrbezirk beendet. ${apiaryName} steht jetzt unter „Beendet“.`,
});
},
onError: (mutationError) =>
setError(complianceErrorText(mutationError, "Der Sperrbezirk konnte nicht beendet werden.")),
@@ -90,7 +126,9 @@ export function EndRestrictionDialog({
</Button>
}
/>
<DialogContent>
<DialogContent
finalFocus={() => (focusAfterClose.current ? document.getElementById(focusAfterClose.current) : true)}
>
<DialogHeader>
<DialogTitle>Sperrbezirk beenden</DialogTitle>
<DialogDescription>
@@ -101,11 +139,7 @@ export function EndRestrictionDialog({
<Field
label="Letzter Tag der Sperre"
required
hint={
stillHolds
? stillHoldsText(endsOn)
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein."
}
hint={endDayHint(startsTodayOrLater, stillHolds, endsOn)}
>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import {
@@ -38,7 +37,6 @@ vi.mock("@/components/document-upload-card", () => ({
</p>
),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function row(overrides: Partial<ApiaryRestriction>): 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 () => {
@@ -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 (
<section aria-labelledby={headingId} className="flex flex-col gap-2">
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none">
{`${title} (${rows.length})`}
</h2>
{rows.length === 0 ? (
@@ -60,6 +62,7 @@ function Section({
state={state}
canManage={canManage}
canRead={canRead}
onEnded={onEnded}
/>
))}
</ul>
@@ -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 <NoComplianceAccess />;
@@ -108,6 +123,9 @@ export default function RestrictionsPage() {
<ErrorState what="Die Sperrbezirke" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
</div>
<p role="status" className="text-sm font-medium empty:hidden">
{announcement}
</p>
<div className="flex flex-col gap-6">
{data ? (
<>
@@ -120,6 +138,7 @@ export default function RestrictionsPage() {
state="active"
canManage={canManage}
canRead={canRead}
onEnded={handleEnded}
/>
{planned.length > 0 ? (
<Section
@@ -131,6 +150,7 @@ export default function RestrictionsPage() {
state="planned"
canManage={canManage}
canRead={canRead}
onEnded={handleEnded}
/>
) : null}
{ended.length > 0 ? (
@@ -143,6 +163,7 @@ export default function RestrictionsPage() {
state="ended"
canManage={canManage}
canRead={canRead}
onEnded={handleEnded}
/>
) : null}
</>
@@ -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
)}
</div>
<p className="text-sm font-medium">{restriction.disease}</p>
<p className="text-sm text-muted-foreground">{periodOf(restriction)}</p>
<p className="text-sm text-muted-foreground">{periodOf(restriction, state)}</p>
{details ? <p className="text-sm text-muted-foreground">{details}</p> : null}
{restriction.notes ? <p className="text-sm text-muted-foreground">{restriction.notes}</p> : null}
<div className="flex flex-wrap gap-2">
@@ -76,7 +79,7 @@ export function RestrictionRow({ restriction, apiaryName, state, canManage, canR
Verfügung
</Button>
) : null}
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} /> : null}
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} onEnded={onEnded} /> : null}
</div>
{canRead && showDocuments ? (
<DocumentUploadCard
@@ -4,7 +4,7 @@ import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { parseDecimal } from "@/lib/medicines/format";
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
import type { NetQuantityDigitRule } from "@/lib/compliance/notice-types";
/** A rule as typed: German decimals stay text until the form is saved. */
@@ -41,20 +41,43 @@ export function digitRuleProblems(rows: readonly DigitRuleText[]): Record<number
const problems: Record<number, string> = {};
let previous = 0;
rows.forEach((row, index) => {
const minMm = parseDecimal(row.minMm);
const heightOk = Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM;
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<number, string>,
): 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 (
<li key={index} className="grid grid-cols-2 gap-3 rounded-lg bg-muted/50 p-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-max-${index}`}>
<Label htmlFor={digitMaxFieldId(index)}>
{isLast ? `Über ${previous || "…"} g` : "Bis (g)"}
</Label>
<Input
id={`digit-max-${index}`}
id={digitMaxFieldId(index)}
inputMode="decimal"
value={isLast ? "" : row.maxGrams}
placeholder={isLast ? "alles darüber" : undefined}
readOnly={readOnly || isLast}
aria-invalid={problems[index] ? true : undefined}
aria-describedby={problems[index] ? `digit-problem-${index}` : undefined}
aria-invalid={problems[index] && isHeightOk(row) ? true : undefined}
aria-describedby={problems[index] && isHeightOk(row) ? `digit-problem-${index}` : undefined}
onChange={(event) => setRow(index, { maxGrams: event.target.value })}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-min-${index}`}>Mindestens (mm)</Label>
<Label htmlFor={digitMinFieldId(index)}>Mindestens (mm)</Label>
<Input
id={`digit-min-${index}`}
id={digitMinFieldId(index)}
inputMode="decimal"
value={row.minMm}
readOnly={readOnly}
aria-invalid={problems[index] && isHeightOk(row) === false ? true : undefined}
aria-describedby={problems[index] && isHeightOk(row) === false ? `digit-problem-${index}` : undefined}
onChange={(event) => setRow(index, { minMm: event.target.value })}
/>
</div>
@@ -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();
@@ -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<Record<string, string>> = {
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<string, string>,
rules: DigitRuleText[],
ruleProblems: Record<number, string>,
): 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<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
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
</div>
</FormSection>
<FormSection title="Erweitert" collapsible defaultOpen={false} fieldCount={2}>
<FormSection
title="Erweitert"
collapsible
defaultOpen={false}
fieldCount={2}
forceOpen={Object.keys(ruleProblems).length > 0}
>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Gezählte Völkerarten</legend>
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
@@ -98,6 +98,27 @@ describe("AppLayout", () => {
pushMock.mockClear();
});
it("starts with a skip link that moves the focus to the page content", async () => {
render(
<AppLayout>
<main>
<p>Seiteninhalt</p>
</main>
</AppLayout>,
);
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"];
+16
View File
@@ -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 (
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
onClick={(event) => {
// 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
</a>
<Sidebar user={user} />
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
@@ -117,6 +132,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
syncEnabled={sessionMode === "online"}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
@@ -83,13 +83,30 @@ describe("DisposalDialog", () => {
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 () => {
@@ -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) {
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="disposal-reason">Grund</Label>
<Label htmlFor="disposal-reason">
Grund
<span className="text-xs font-normal text-foreground/80">Pflicht</span>
</Label>
<Input
id="disposal-reason"
className="h-11 sm:h-11"
@@ -142,7 +153,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
{exceedsRest ? (
<span className="inline-flex items-start gap-1 font-semibold text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
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)}).
</span>
) : null}
</p>
@@ -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
</h2>
<div className="overflow-x-auto rounded-xl border">
<Table aria-label="Bewegungsbuch">
<TableHeader>
<Table aria-label="Bewegungsbuch" className="max-sm:block">
<TableHeader className="max-sm:sr-only">
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Art</TableHead>
@@ -161,16 +170,23 @@ function MovementBook({ lot }: { lot: LotDetailView }) {
<TableHead>Grund</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableBody className="max-sm:block">
{rows.map((row) => (
<TableRow key={row.key}>
<TableCell>{formatDay(row.day)}</TableCell>
<TableCell>{row.label}</TableCell>
<TableCell className="text-right">{formatSignedQuantity(row.quantity, lot.unit)}</TableCell>
<TableCell className="text-right">
<TableRow key={row.key} className={BOOK_ROW}>
<TableCell className={BOOK_CELL}>{formatDay(row.day)}</TableCell>
<TableCell className={`${BOOK_CELL} max-sm:text-right`}>{row.label}</TableCell>
<TableCell className={`${BOOK_CELL} text-right`}>
{formatSignedQuantity(row.quantity, lot.unit)}
</TableCell>
<TableCell className={`${BOOK_CELL} text-right ${BOOK_LABELLED}`} data-label="Stand danach">
<RestText value={row.balance} unit={lot.unit} />
</TableCell>
<TableCell className="whitespace-normal break-words">{row.reason ?? "—"}</TableCell>
<TableCell
className={`${BOOK_CELL} whitespace-normal break-words max-sm:col-span-2 ${BOOK_LABELLED}`}
data-label="Grund"
>
{row.reason ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -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();
@@ -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 : <Badge variant="secondary">Deaktiviert</Badge>}
fields={[
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
{ label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
{ label: "Nächster Verfall", value: <ExpiryText day={medicine.nextExpiry} /> },
]}
/>
))}
@@ -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());
});
});
@@ -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<string | null>(null);
const [isSaved, setIsSaved] = useState(false);
const savedRef = useRef<HTMLParagraphElement>(null);
const [originCountry, setOriginCountry] = useState(settings.originCountry);
const [originCountryError, setOriginCountryError] = useState<string | null>(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<HTMLFormElement>) {
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 }) {
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{isSaved ? (
<p
ref={savedRef}
role="status"
tabIndex={-1}
className="rounded-lg bg-muted px-3 py-2 text-sm font-medium focus:outline-2 focus:outline-ring"
>
Gespeichert. Die Angaben gelten ab jetzt.
</p>
) : null}
<p className="text-xs text-muted-foreground">
Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link
zu entfernen.
@@ -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<Extract<LabelElement, { type: "text" }>>): 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(
<LabelCanvas
widthMm={90}
heightMm={35}
elements={elements}
selectedId={null}
onSelect={vi.fn()}
onChangeElement={vi.fn()}
/>,
);
}
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");
});
});
@@ -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 (
<div
className="size-full overflow-hidden whitespace-pre-wrap"
className={cn(
"size-full overflow-hidden whitespace-pre-wrap",
key && "underline decoration-dotted decoration-1 underline-offset-2",
)}
title={key ? `Platzhalter {{${key}}} – Beispielwert` : undefined}
data-placeholder={key ?? undefined}
style={{
fontFamily: FONT_CSS[element.fontFamily],
fontSize: element.fontSize * PT_TO_MM * scale,
@@ -223,3 +223,33 @@ describe("LabelDesigner compliance check", () => {
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(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
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);
});
});
@@ -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 (
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base">Elemente</CardTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
render={
<label className="cursor-pointer">
<Upload className="size-4" />
Importieren
<input
type="file"
accept="application/json"
className="hidden"
onChange={handleImportFile}
/>
</label>
}
/>
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
<Download className="size-4" />
Exportieren
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() => addElement(newTextElement())}
>
<Type className="size-4" />
Text
</Button>
{/* 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. */}
<Select
value={null}
onValueChange={(value) => {
const key = PLACEHOLDER_KEYS.find((candidate) => candidate === value);
if (key) handlePlaceholderInsert(key);
}}
>
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
<SelectValue>Platzhalter einfügen</SelectValue>
</SelectTrigger>
<SelectContent>
{PLACEHOLDER_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{PLACEHOLDER_LABELS[key]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
}
>
<QrCode className="size-4" />
QR-Code
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({
id: newId(),
type: "line",
x: 5,
y: 5,
length: 40,
thickness: 1,
color: "#c9a34e",
orientation: "horizontal",
})
}
>
<Minus className="size-4" />
Linie
</Button>
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
<label className="cursor-pointer">
<ImageIcon className="size-4" />
Bild/Logo
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
</label>
} />
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
aria-disabled={canInsertBlock ? undefined : true}
aria-describedby={canInsertBlock ? undefined : insertHintId}
onClick={() => {
if (canInsertBlock) insertComplianceBlock();
}}
>
<ShieldCheck className="size-4" />
Pflichtangaben-Block einfügen
</Button>
{canInsertBlock ? null : (
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
Geht, sobald die erste Prüfung da ist.
</p>
)}
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={zoom <= ZOOM_MIN}
onClick={() => setZoom((current) => nextZoomStep(current, -1))}
aria-label="Verkleinern"
>
<ZoomOut className="size-3.5" aria-hidden />
</Button>
<span className="w-12 text-center text-sm text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={zoom >= ZOOM_MAX}
onClick={() => setZoom((current) => nextZoomStep(current, 1))}
aria-label="Vergrößern"
>
<ZoomIn className="size-3.5" aria-hidden />
</Button>
</div>
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
<LabelCanvas
widthMm={widthMm}
heightMm={heightMm}
elements={elements}
selectedId={selectedId}
zoom={zoom}
onSelect={setSelectedId}
onChangeElement={changeElement}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Vorlage</CardTitle>
@@ -622,196 +805,49 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base">Elemente</CardTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
render={
<label className="cursor-pointer">
<Upload className="size-4" />
Importieren
<input
type="file"
accept="application/json"
className="hidden"
onChange={handleImportFile}
/>
</label>
}
/>
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
<Download className="size-4" />
Exportieren
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() => addElement(newTextElement())}
>
<Type className="size-4" />
Text
</Button>
{/* 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. */}
<Select
value={null}
onValueChange={(value) => {
const key = PLACEHOLDER_KEYS.find((candidate) => candidate === value);
if (key) handlePlaceholderInsert(key);
}}
>
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
<SelectValue>Platzhalter einfügen</SelectValue>
</SelectTrigger>
<SelectContent>
{PLACEHOLDER_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{PLACEHOLDER_LABELS[key]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
}
>
<QrCode className="size-4" />
QR-Code
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({
id: newId(),
type: "line",
x: 5,
y: 5,
length: 40,
thickness: 1,
color: "#c9a34e",
orientation: "horizontal",
})
}
>
<Minus className="size-4" />
Linie
</Button>
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
<label className="cursor-pointer">
<ImageIcon className="size-4" />
Bild/Logo
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
</label>
} />
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
aria-disabled={canInsertBlock ? undefined : true}
aria-describedby={canInsertBlock ? undefined : insertHintId}
onClick={() => {
if (canInsertBlock) insertComplianceBlock();
}}
>
<ShieldCheck className="size-4" />
Pflichtangaben-Block einfügen
</Button>
{canInsertBlock ? null : (
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
Geht, sobald die erste Prüfung da ist.
</p>
)}
</CardContent>
</Card>
<LabelCompliancePanel
result={check.result}
pending={check.pending}
error={check.error}
stale={check.stale}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={zoom <= ZOOM_MIN}
onClick={() => setZoom((current) => nextZoomStep(current, -1))}
>
<ZoomOut className="size-3.5" />
</Button>
<span className="w-12 text-center text-sm text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={zoom >= ZOOM_MAX}
onClick={() => setZoom((current) => nextZoomStep(current, 1))}
>
<ZoomIn className="size-3.5" />
</Button>
</div>
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
<LabelCanvas
widthMm={widthMm}
heightMm={heightMm}
elements={elements}
selectedId={selectedId}
zoom={zoom}
onSelect={setSelectedId}
onChangeElement={changeElement}
/>
</div>
<Button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
{isSubmitting ? "Speichert…" : submitLabel}
</Button>
</div>
<Card className="sticky top-(--header-clearance)">
<CardHeader>
<CardTitle className="text-base">Eigenschaften</CardTitle>
</CardHeader>
<CardContent>
{selectedElement ? (
<ElementPropertiesPanel
element={selectedElement}
onChange={(patch) => changeElement(selectedElement.id, patch)}
onDelete={() => deleteElement(selectedElement.id)}
onDuplicate={() => duplicateElement(selectedElement.id)}
/>
) : (
<p className="text-sm text-muted-foreground">
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.
</p>
)}
</CardContent>
</Card>
<div className="flex flex-col gap-4 lg:sticky lg:top-(--header-clearance) lg:max-h-[calc(100dvh-var(--header-clearance)-1rem)] lg:overflow-y-auto">
<details
open={complianceOpen}
onToggle={(event) => setComplianceOpen(event.currentTarget.open)}
className="group rounded-xl"
>
<summary className="min-h-11 cursor-pointer list-none rounded-xl border bg-card px-4 py-3 text-sm font-medium group-open:mb-3 lg:hidden">
Pflichtangaben prüfen
</summary>
<LabelCompliancePanel
result={check.result}
pending={check.pending}
error={check.error}
stale={check.stale}
/>
</details>
<Card>
<CardHeader>
<CardTitle className="text-base">Eigenschaften</CardTitle>
</CardHeader>
<CardContent>
{selectedElement ? (
<ElementPropertiesPanel
element={selectedElement}
onChange={(patch) => changeElement(selectedElement.id, patch)}
onDelete={() => deleteElement(selectedElement.id)}
onDuplicate={() => duplicateElement(selectedElement.id)}
/>
) : (
<p className="text-sm text-muted-foreground">
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.
</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -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"),
@@ -404,7 +404,7 @@ export default function NewTreatmentPage() {
)}
<Field
label="Wartezeit (Tage)"
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
hint={`Laut Packungsbeilage des Mittels – solange darf aus ${isBulk && values.bulkColonyIds.length > 1 ? "diesen Völkern" : "diesem Volk"} kein Honig geerntet werden.`}
>
{({ id, describedBy }) => (
<>
@@ -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(<NoComplianceAccess />);
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");
});
});
@@ -4,6 +4,7 @@ import Link from "next/link";
export function NoComplianceAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<h1 className="font-heading text-xl font-semibold tracking-tight">Prüfbereitschaft</h1>
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
@@ -48,7 +48,7 @@ export function Field({
const Caption = group ? "span" : "label";
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
<Caption
id={`${id}-label`}
htmlFor={group ? undefined : id}
@@ -17,6 +17,8 @@ interface FormSectionProps {
collapsible?: boolean;
defaultOpen?: boolean;
fieldCount?: number;
/** Shows the content although the section is folded, e.g. while a field inside has a problem. */
forceOpen?: boolean;
children: ReactNode;
}
@@ -27,9 +29,11 @@ export function FormSection({
collapsible = false,
defaultOpen = true,
fieldCount,
forceOpen = false,
children,
}: FormSectionProps) {
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
const [openState, setOpen] = useState(collapsible ? defaultOpen : true);
const open = openState || forceOpen;
const heading = (
<span className="flex items-center gap-2">
{step !== undefined ? (
@@ -45,7 +49,7 @@ export function FormSection({
);
return (
<fieldset className="flex flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
<fieldset className="flex min-w-0 flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
{collapsible ? (
<legend className="contents">
<button
@@ -77,7 +81,7 @@ export function FormSection({
{description && open ? (
<p className="-mt-2 text-sm text-muted-foreground">{description}</p>
) : null}
{open ? <div className="flex flex-col gap-4">{children}</div> : null}
{open ? <div className="flex min-w-0 flex-col gap-4">{children}</div> : null}
</fieldset>
);
}
@@ -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(<Harness initial="cmg3xabc" />);
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");
});
});
@@ -82,12 +82,15 @@ export function RecordSelect<T extends { id: string }>({
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}
>
<SelectValue placeholder={placeholder}>
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
<SelectValue placeholder={placeholder} className="min-w-0">
{/* A long label is cut with an ellipsis; the list shows it in full. */}
<span className="min-w-0 truncate" data-slot="record-select-label">
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -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 (
<span className="inline-flex max-w-full items-start gap-1 font-semibold whitespace-normal text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
{formatDay(day)} · abgelaufen
</span>
);
}
return <span>{formatDay(day)}</span>;
}
@@ -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(<NoReadAccess />);
expect(screen.getByRole("heading", { level: 1, name: "Tierarzneimittel" })).toBeInTheDocument();
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ import Link from "next/link";
export function NoReadAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<h1 className="font-heading text-xl font-semibold tracking-tight">Tierarzneimittel</h1>
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
@@ -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 (
<Field label="Dosis je Volk" id={`${idPrefix}-dose`} hint={hint} error={error}>
<Field
label="Dosis je Volk"
id={`${idPrefix}-dose`}
hint={hint}
error={error ?? doseFormatError(value)}
>
{({ id, describedBy, invalid }) => (
<>
<div className="flex items-center gap-2">
@@ -260,6 +271,11 @@ function DoseField({
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
<CatalogueNote id={id} shown={isFromCatalogue} />
<div role="status" aria-live="polite" className="empty:hidden">
{notice ? (
<p className="rounded-lg bg-warning/20 px-3 py-2 text-sm">{notice}</p>
) : null}
</div>
</>
)}
</Field>
@@ -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("<html>", { 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);
+17 -3
View File
@@ -159,9 +159,23 @@ export async function apiFetch<T>(
throw await apiErrorFrom(response);
}
if (response.status === 204) {
return parseSuccessBody<T>(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<T>(response: Response): Promise<T> {
if (response.status === 204 || response.status === 205) {
return undefined as T;
}
return response.json() as Promise<T>;
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;
}
@@ -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>): 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([]);
});
});
@@ -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;
});
}
@@ -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;
@@ -35,3 +35,23 @@ export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
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<PlaceholderKey, string> = {
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",
};
@@ -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);
@@ -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("");
+29 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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)