fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6c390efe7b
commit
e712c5d4d1
64 files changed
+1472
-298
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
+38
-1
@@ -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 () => {
|
||||
|
||||
+32
-1
@@ -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"];
|
||||
|
||||
@@ -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.
|
||||
|
||||
+54
@@ -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,
|
||||
|
||||
+30
@@ -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);
|
||||
});
|
||||
});
|
||||
+198
-162
@@ -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,128 +805,20 @@ 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 type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
|
||||
{isSubmitting ? "Speichert…" : submitLabel}
|
||||
</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 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}
|
||||
@@ -751,48 +826,8 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
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)">
|
||||
</details>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Eigenschaften</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -813,5 +848,6 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
</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}>
|
||||
<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);
|
||||
|
||||
@@ -159,9 +159,23 @@ export async function apiFetch<T>(
|
||||
throw await apiErrorFrom(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
return parseSuccessBody<T>(response);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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("");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user