feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
Backend Image / build-and-push (push) Successful in 6m52s
Frontend Image / build-and-push (push) Failing after 12m28s
Manifests / validate (push) Successful in 22s

- Treatment form and bulk entry: catalogue medicine or free text, lot with
  oldest preselected on create, lot fields from the lot, dose with at most
  3 decimals, catalogue prefill with explicit take-over of the withdrawal
  period on a medicine switch, ?editTreatment deep link.
- Authority notices: list with year filter, create with draft dedupe link,
  detail steps with PDF download and mark-sent, settings with MM-TT check,
  digit-rule editor and "Annahme – bitte prüfen" notes; COMPLIANCE label
  in notifications and Telegram categories.
- Restricted zone: page with active/planned/ended, create and end dialogs
  (endsOn inclusive), red band at the apiary, guards on move, merge, edit
  and new colony, override only with compliance:manage, harvest warning.
- ARCHITECTURE.md §12.11, Annahmen 91–94; TASKS.md T6 done with open items.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 17:33:55 +02:00
1 parent 13dda0e139
commit 8188e4cab3
90 files changed
+8069 -265

No files matched your search

@@ -0,0 +1,18 @@
import Link from "next/link";
/** Shown instead of a page whose data needs `compliance:read`: a retry would not help. */
export function NoComplianceAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
<Link
href="/dashboard"
className="inline-flex min-h-11 items-center self-start text-sm underline underline-offset-2"
>
Zurück zum Start
</Link>
</main>
);
}
@@ -0,0 +1,27 @@
import { CheckCircle2, Circle, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { NOTICE_STATUS_LABEL } from "@/lib/compliance/notice-labels";
import type { AuthorityNoticeStatus } from "@/lib/compliance/notice-types";
const STATUS_ICON: Record<AuthorityNoticeStatus, LucideIcon> = {
DRAFT: Circle,
SENT: CheckCircle2,
CANCELLED: XCircle,
};
const STATUS_VARIANT: Record<AuthorityNoticeStatus, "outline" | "default" | "secondary"> = {
DRAFT: "outline",
SENT: "default",
CANCELLED: "secondary",
};
/** The status of a notice as symbol AND word, never colour alone. */
export function NoticeStatus({ status }: { status: AuthorityNoticeStatus }) {
const Icon = STATUS_ICON[status];
return (
<Badge variant={STATUS_VARIANT[status]} className="h-6 gap-1.5 px-2.5 text-sm">
<Icon aria-hidden />
{NOTICE_STATUS_LABEL[status]}
</Badge>
);
}
@@ -0,0 +1,21 @@
"use client";
import type { RestrictionGuard } from "@/lib/compliance/use-restriction-guard";
import { RestrictionNotice } from "./restriction-notice";
import { RestrictionOverride } from "./restriction-override";
/** The red notice and, only while something blocks, the folded override. */
export function RestrictionGuardPanel({
guard,
blockedText,
}: {
guard: RestrictionGuard;
blockedText: string;
}) {
return (
<>
<RestrictionNotice live id={guard.noticeId} restrictions={guard.blocking} blockedText={blockedText} />
{guard.isBlocked ? <RestrictionOverride key={guard.resetKey} onReason={guard.setReason} /> : null}
</>
);
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import {
HarvestRestrictionWarning,
MoveHint,
RestrictionBand,
RestrictionNotice,
} from "./restriction-notice";
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn(),
}));
const restriction = {
id: "r-1",
apiaryId: "a-1",
apiaryName: "Obstwiese",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
};
describe("RestrictionNotice", () => {
it("names the apiary, the disease, the start day and the blocked action in red with a symbol", () => {
render(<RestrictionNotice id="notice" restrictions={[restriction]} />);
const text = screen.getByText(
"Stand Obstwiese liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Umsetzen ist gesperrt.",
);
const box = text.closest("[data-restriction-notice]");
expect(box).toHaveClass("text-destructive");
expect(box?.querySelector("svg")).not.toBeNull();
expect(box).toHaveAttribute("id", "notice");
});
it("says one sentence per restricted apiary and takes another blocked action", () => {
render(
<RestrictionNotice
blockedText="Anlegen ist gesperrt."
restrictions={[restriction, { ...restriction, id: "r-2", apiaryName: "Garten", disease: "Kleiner Beutenkäfer" }]}
/>,
);
expect(screen.getAllByText(/liegt im Sperrbezirk/)).toHaveLength(2);
expect(screen.getAllByText(/Anlegen ist gesperrt\.$/)).toHaveLength(2);
});
it("renders nothing without restrictions", () => {
const { container } = render(<RestrictionNotice restrictions={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("MoveHint", () => {
it("shows the fixed BienSeuchV § 5 hint", () => {
render(<MoveHint />);
expect(
screen.getByText(
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.",
),
).toBeInTheDocument();
});
});
describe("RestrictionBand", () => {
it("is a red band with a link to the list when the reader may open it", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList />);
expect(screen.getByText(/liegt im Sperrbezirk/).closest("[data-restriction-band]")).toHaveClass(
"text-destructive",
);
expect(screen.getByRole("link", { name: "Sperrbezirke ansehen" })).toHaveAttribute(
"href",
"/compliance/restrictions",
);
});
it("has no link without the right to read the list", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList={false} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
describe("HarvestRestrictionWarning", () => {
beforeEach(() => {
vi.mocked(fetchActiveRestrictions).mockReset();
});
function renderWarning(apiaryId: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HarvestRestrictionWarning apiaryId={apiaryId} />
</QueryClientProvider>,
);
}
it("warns only for a restricted apiary and keeps a polite live region either way", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
const { container } = renderWarning("a-1");
expect(
await screen.findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
expect(container.querySelector("[aria-live=polite]")).not.toBeNull();
});
it("says nothing for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
renderWarning("a-2");
await vi.waitFor(() => expect(fetchActiveRestrictions).toHaveBeenCalled());
expect(screen.queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { AlertTriangle, OctagonAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { restrictionsAmong, type ActiveRestriction } from "@/lib/compliance/restrictions-api";
import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
import { formatDay } from "@/lib/medicines/format";
/** The blocked action, worded for the place the notice appears in. */
export const BLOCKED_TEXT = {
move: "Umsetzen ist gesperrt.",
create: "Anlegen ist gesperrt.",
merge: "Vereinigen ist gesperrt.",
correct: "Standwechsel ist gesperrt.",
} as const;
const MOVE_HINT =
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.";
function sentenceOf(restriction: ActiveRestriction): string {
return `Stand ${restriction.apiaryName} liegt im Sperrbezirk (${restriction.disease}, seit ${formatDay(restriction.startsOn)}).`;
}
interface RestrictionNoticeProps {
restrictions: readonly ActiveRestriction[];
/** The sentence that says what is blocked; defaults to the move. */
blockedText?: string;
/** Lets a disabled button point at the notice (`aria-describedby`). */
id?: string;
/**
* Wraps the notice in a polite live region that stays in the page, so the
* notice is announced when it appears. Without it the notice is just a box.
*/
live?: boolean;
}
/**
* The red box: symbol, the word "Sperrbezirk" and the text in the
* `destructive` colour - never colour alone. One sentence per restriction.
*/
export function RestrictionNotice({
restrictions,
blockedText = BLOCKED_TEXT.move,
id,
live = false,
}: RestrictionNoticeProps) {
const box =
restrictions.length === 0 ? null : (
<div
id={id}
data-restriction-notice
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive"
>
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>{`${sentenceOf(restriction)} ${blockedText}`}</p>
))}
</div>
</div>
);
return live ? <div aria-live="polite">{box}</div> : box;
}
/** Fixed hint of the move dialog; the wording is prescribed, not configurable. */
export function MoveHint() {
return <p className="text-sm text-muted-foreground">{MOVE_HINT}</p>;
}
/** The red band at the top of an apiary page while a restriction is active. */
export function RestrictionBand({
restrictions,
canOpenList,
}: {
restrictions: readonly ActiveRestriction[];
/** The list needs `compliance:read`; without it no link is offered. */
canOpenList: boolean;
}) {
if (restrictions.length === 0) return null;
return (
<section
data-restriction-band
aria-label="Sperrbezirk"
className="flex flex-col gap-3 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2">
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>
{`${sentenceOf(restriction)} Völker anlegen, umsetzen oder vereinigen ist gesperrt.`}
</p>
))}
</div>
</div>
{canOpenList ? (
<Button
variant="outline"
size="touch"
render={<Link href="/compliance/restrictions" />}
>
Sperrbezirke ansehen
</Button>
) : null}
</section>
);
}
/**
* Honey and combs from a restricted zone carry conditions. Only a warning,
* never a block: the harvest is saved all the same.
*/
export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
const { restrictions } = useActiveRestrictions();
const restricted = apiaryId !== "" && restrictionsAmong([apiaryId], restrictions).length > 0;
return (
<div aria-live="polite">
{restricted ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
</p>
) : null}
</div>
);
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RestrictionOverride } from "./restriction-override";
let permissions = ["compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const REASON = "Amtstierarzt hat die Ausnahme mündlich erlaubt.";
async function open() {
await userEvent.click(screen.getByRole("button", { name: /Ausnahme mit Begründung/ }));
}
describe("RestrictionOverride", () => {
beforeEach(() => {
permissions = ["compliance:manage"];
});
it("renders nothing without compliance:manage", () => {
permissions = ["compliance:read"];
const { container } = render(<RestrictionOverride onReason={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("is folded until opened", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /Ausnahme mit Begründung/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByLabelText(/Begründung der Ausnahme/)).not.toBeInTheDocument();
await open();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByLabelText(/Begründung der Ausnahme/)).toBeInTheDocument();
});
it("keeps the confirm button off below 10 characters and reports nothing", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), "zu kurz");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
expect(onReason).not.toHaveBeenCalled();
});
it("reports the trimmed reason only after the confirmation, and takes it back", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), ` ${REASON} `);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
const dialog = await screen.findByRole("alertdialog");
expect(onReason).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme bestätigen" }));
expect(onReason).toHaveBeenLastCalledWith(REASON);
expect(screen.getByRole("status")).toHaveTextContent(REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme zurücknehmen" }));
expect(onReason).toHaveBeenLastCalledWith(null);
});
it("keeps the confirm button off for a reason of only blanks", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), " ");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
});
it("caps the reason at 500 characters", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
const field = screen.getByLabelText(/Begründung der Ausnahme/) as HTMLTextAreaElement;
expect(field).toHaveAttribute("maxlength", "500");
await userEvent.click(field);
await userEvent.paste("x".repeat(501));
expect(field.value).toHaveLength(500);
});
it("does not call onReason while the confirmation is still open", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(onReason).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { ChevronDown, ShieldCheck } from "lucide-react";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth/auth-context";
import { cn } from "@/lib/utils";
/** The server's bounds for `restrictionOverrideReason`. */
export const MIN_OVERRIDE_REASON_LENGTH = 10;
export const MAX_OVERRIDE_REASON_LENGTH = 500;
interface RestrictionOverrideProps {
/** The confirmed reason, or `null` while there is none (or it was taken back). */
onReason: (reason: string | null) => void;
}
/**
* The exception from a restricted zone: a folded section with a reason that
* counts only after a second question. Only with `compliance:manage` - the
* server answers 403 to anyone else, so the field is not offered at all.
*/
export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
const { hasPermission } = useAuth();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [confirmed, setConfirmed] = useState<string | null>(null);
if (!hasPermission("compliance:manage")) return null;
const trimmed = text.trim();
const tooShort = trimmed.length < MIN_OVERRIDE_REASON_LENGTH;
function takeBack() {
setConfirmed(null);
onReason(null);
}
return (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg text-left text-sm font-medium"
>
Ausnahme mit Begründung
<ChevronDown
aria-hidden
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
/>
</button>
{open ? (
<div className="flex flex-col gap-3">
<Field
label="Begründung der Ausnahme"
required
hint={`Mindestens ${MIN_OVERRIDE_REASON_LENGTH} Zeichen. Sie wird in der Historie festgehalten.`}
>
{({ id, describedBy }) => (
<Textarea
id={id}
aria-describedby={describedBy}
maxLength={MAX_OVERRIDE_REASON_LENGTH}
readOnly={confirmed !== null}
value={text}
onChange={(event) => setText(event.target.value)}
/>
)}
</Field>
{confirmed === null ? (
<ConfirmDialog
triggerLabel="Ausnahme nutzen"
triggerVariant="outline"
triggerSize="touch"
triggerDisabled={tooShort}
title="Ausnahme vom Sperrbezirk nutzen?"
description="Du setzt dich über den Sperrbezirk hinweg. Deine Begründung wird mit deinem Namen festgehalten. Nutze die Ausnahme nur nach Rücksprache mit dem Veterinäramt."
confirmLabel="Ausnahme bestätigen"
destructive={false}
onConfirm={() => {
setConfirmed(trimmed);
onReason(trimmed);
}}
/>
) : (
<Button type="button" variant="outline" size="touch" onClick={takeBack}>
Ausnahme zurücknehmen
</Button>
)}
</div>
) : null}
<p role="status" className="flex items-start gap-2 text-sm font-medium">
{confirmed !== null ? (
<>
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{`Ausnahme gilt: ${confirmed}`}</span>
</>
) : null}
</p>
</div>
);
}
@@ -24,6 +24,8 @@ interface ConfirmDialogProps {
triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string;
triggerDisabled?: boolean;
/** Off for now but still reachable by keyboard, so `triggerDescribedBy` can be read. */
triggerAriaDisabled?: boolean;
/** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */
triggerDescribedBy?: string;
title: string;
@@ -41,6 +43,7 @@ export function ConfirmDialog({
triggerSize = "default",
triggerClassName,
triggerDisabled,
triggerAriaDisabled,
triggerDescribedBy,
title,
description,
@@ -60,8 +63,11 @@ export function ConfirmDialog({
className={cn(triggerClassName)}
aria-label={triggerAriaLabel}
disabled={triggerDisabled}
aria-disabled={triggerAriaDisabled || undefined}
aria-describedby={triggerDescribedBy}
onClick={() => setOpen(true)}
onClick={() => {
if (!triggerAriaDisabled) setOpen(true);
}}
>
{triggerLabel}
</Button>
@@ -41,6 +41,7 @@ const CATEGORY_LABEL: Record<NotificationCategory, string> = {
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
COMPLIANCE: "Behörden",
};
/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */
@@ -0,0 +1,126 @@
import { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { BookingNotes, MedicineLotFields } from "./medicine-lot-fields";
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
const STATUS = { isLoading: false, isError: false, isRetrying: false, retry: vi.fn() };
function choiceOf(overrides: Partial<MedicineChoice> = {}): MedicineChoice {
return {
canRead: true,
medicines: [medicine({ id: "m1", name: "Apivar" })],
allMedicines: [medicine({ id: "m1", name: "Apivar" })],
medicinesStatus: STATUS,
medicine: undefined,
lots: undefined,
lotsStatus: STATUS,
lot: undefined,
preselectedLotId: null,
decideLot: vi.fn(),
undecideLot: vi.fn(),
...overrides,
};
}
function wrap(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
function fields(props: Partial<React.ComponentProps<typeof MedicineLotFields>>) {
return (
<MedicineLotFields
idPrefix="t"
choice={choiceOf()}
product=""
medicineId=""
medicineLotId=""
doseQuantity=""
filled={{}}
onProductChange={vi.fn()}
onMedicineChange={vi.fn()}
onLotChange={vi.fn()}
onDoseChange={vi.fn()}
{...props}
/>
);
}
describe("MedicineLotFields", () => {
it("hands the chosen catalogue medicine to the form", async () => {
const onMedicineChange = vi.fn();
wrap(fields({ onMedicineChange }));
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
expect(onMedicineChange).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", name: "Apivar" }));
});
it("hands null to the form for the last option", async () => {
const onMedicineChange = vi.fn();
wrap(
fields({
onMedicineChange,
medicineId: "m1",
choice: choiceOf({ medicine: medicine({ id: "m1", name: "Apivar" }) }),
}),
);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Anderes Mittel" }));
expect(onMedicineChange).toHaveBeenCalledWith(null);
});
it("names the unit of the medicine next to the dose", () => {
const apivar = medicine({ id: "m1", name: "Apivar", unit: "PIECE" });
wrap(fields({ choice: choiceOf({ medicine: apivar }), medicineId: "m1", doseQuantity: "2" }));
expect(screen.getByText("Stück")).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis/)).toHaveValue("2");
});
});
describe("BookingNotes", () => {
const pack = lot({ unit: "ML", remaining: 100, expiresOn: "2026-06-30" });
it("shows nothing without a pack", () => {
wrap(
<BookingNotes
choice={choiceOf()}
doseQuantity="30"
colonyCount={1}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.queryByText(/Bucht/)).not.toBeInTheDocument();
});
it("books, warns about expiry and shortage and says saving stays possible", () => {
wrap(
<BookingNotes
choice={choiceOf({ lot: pack })}
doseQuantity="30"
colonyCount={5}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.getByText("Bucht 30 ml je Volk ab (× 5 = 150 ml).")).toBeInTheDocument();
expect(screen.getByText(/abgelaufen/)).toBeInTheDocument();
expect(screen.getByText(/reicht nicht: Rest 100 ml, gebraucht 150 ml/)).toBeInTheDocument();
expect(screen.getByText("Speichern ist trotzdem möglich.")).toBeInTheDocument();
});
});
@@ -0,0 +1,358 @@
"use client";
import { AlertTriangle, PackageMinus, Thermometer } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
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 { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
import { temperatureHint } from "@/lib/treatments/temperature-hint";
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
import { PrefillStatus } from "@/components/warnings/prefill-status";
const OTHER_MEDICINE = { id: "__other__", name: "Anderes Mittel" } as const;
const NO_LOT_LABEL = "Ohne Packung (nichts abbuchen)";
type MedicineOption = Pick<MedicineView, "id" | "name">;
/** Visible note that a value comes from the catalogue and is still editable. */
export function CatalogueNote({ id, shown }: { id: string; shown: boolean | undefined }) {
if (!shown) return null;
return (
<p id={`${id}-catalogue`} className="text-xs font-medium text-muted-foreground">
aus Katalog
</p>
);
}
/** The control's aria-describedby, extended by the catalogue note when one is shown. */
export function describedByCatalogue(
id: string,
describedBy: string | undefined,
shown: boolean | undefined,
): string | undefined {
return [describedBy, shown ? `${id}-catalogue` : undefined].filter(Boolean).join(" ") || undefined;
}
export interface MedicineLotFieldsProps {
idPrefix: string;
choice: MedicineChoice;
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
productError?: string;
doseError?: string;
/** Which fields carry the note „aus Katalog“. */
filled: CatalogueFilled;
onProductChange: (product: string) => void;
/** `null` is „Anderes Mittel“. */
onMedicineChange: (medicine: MedicineView | null) => void;
/** `""` is „Ohne Packung“. */
onLotChange: (lotId: string) => void;
onDoseChange: (dose: string) => void;
}
/**
* „Mittel“ of a treatment: a catalogue medicine (when the user may read the
* catalogue) or any other by name, then its pack and the dose per colony.
* Everything that is chosen here stays visible and editable.
*/
export function MedicineLotFields({
idPrefix,
choice,
product,
medicineId,
medicineLotId,
doseQuantity,
productError,
doseError,
filled,
onProductChange,
onMedicineChange,
onLotChange,
onDoseChange,
}: MedicineLotFieldsProps) {
const options: MedicineOption[] = [...(choice.medicines ?? []), OTHER_MEDICINE];
const labelOptions: MedicineOption[] = [...(choice.allMedicines ?? []), OTHER_MEDICINE];
const status = choice.medicinesStatus;
return (
<>
<Field label="Aus dem Katalog wählen" id={`${idPrefix}-medicine`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineId || OTHER_MEDICINE.id}
onChange={(next) =>
onMedicineChange(
choice.medicines?.find((candidate) => candidate.id === next) ?? null,
)
}
items={options}
labelItems={labelOptions}
getLabel={(option) => option.name}
/>
<PrefillStatus
enabled={choice.canRead}
isLoading={status.isLoading}
isError={status.isError}
isRetrying={status.isRetrying}
onRetry={status.retry}
showLoading={false}
/>
</>
)}
</Field>
{medicineId === "" ? (
<Field
label="Mittel"
required
error={productError}
hint="Handelsname, z. B. „Oxuvar“"
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="product"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={product}
onChange={(event) => onProductChange(event.target.value)}
/>
)}
</Field>
) : (
<>
<LotField
idPrefix={idPrefix}
choice={choice}
medicineLotId={medicineLotId}
onLotChange={onLotChange}
/>
<DoseField
idPrefix={idPrefix}
medicine={choice.medicine}
hasLot={medicineLotId !== ""}
value={doseQuantity}
error={doseError}
isFromCatalogue={filled.doseQuantity}
onChange={onDoseChange}
/>
</>
)}
</>
);
}
function LotField({
idPrefix,
choice,
medicineLotId,
onLotChange,
}: {
idPrefix: string;
choice: MedicineChoice;
medicineLotId: string;
onLotChange: (lotId: string) => void;
}) {
const { lots, lotsStatus } = choice;
const isEmpty = lots !== undefined && lots.length === 0;
return (
<Field label="Packung" id={`${idPrefix}-lot`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineLotId}
onChange={onLotChange}
items={lots}
getLabel={(lot: LotView) => lotLabel(lot)}
noneLabel={NO_LOT_LABEL}
dataField="medicineLotId"
/>
<div role="status" aria-live="polite" className="empty:hidden">
{lotsStatus.isLoading ? (
<p className="text-sm text-muted-foreground">Packungen werden geladen …</p>
) : null}
{choice.preselectedLotId ? (
<p className="text-xs font-medium text-muted-foreground">
Die Packung mit dem frühesten Verfall ist vorgewählt.
</p>
) : null}
{isEmpty ? (
<p className="text-sm text-muted-foreground">
Keine Packung mit Rest im Katalog. Du kannst trotzdem speichern, dann wird nichts
abgebucht.
</p>
) : null}
</div>
{lotsStatus.isError ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<span>Packungen konnten nicht geladen werden.</span>
<Button
type="button"
variant="outline"
size="touch"
onClick={lotsStatus.retry}
disabled={lotsStatus.isRetrying}
>
Erneut versuchen
</Button>
</div>
) : null}
</>
)}
</Field>
);
}
function DoseField({
idPrefix,
medicine,
hasLot,
value,
error,
isFromCatalogue,
onChange,
}: {
idPrefix: string;
medicine: MedicineView | undefined;
hasLot: boolean;
value: string;
error: string | undefined;
isFromCatalogue: boolean | undefined;
onChange: (dose: string) => void;
}) {
const unit = medicine ? MEDICINE_UNIT_LABELS[medicine.unit] : null;
const hint = hasLot
? "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}>
{({ id, describedBy, invalid }) => (
<>
<div className="flex items-center gap-2">
<Input
id={id}
data-field="doseQuantity"
inputMode="decimal"
autoComplete="off"
className="h-11 w-28"
aria-describedby={describedByCatalogue(id, describedBy, isFromCatalogue)}
aria-invalid={invalid}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
<CatalogueNote id={id} shown={isFromCatalogue} />
</>
)}
</Field>
);
}
export interface BookingNotesProps {
choice: MedicineChoice;
doseQuantity: string;
/** How many colonies the dose is booked for (1 for a single capture). */
colonyCount: number;
/** The treatment day "YYYY-MM-DD"; an expired pack is judged on it. */
treatmentDay: string;
/** The apiary the weather of the temperature hint belongs to. */
apiaryId: string | null;
canReadApiaries: boolean;
}
/**
* What saving does, above „Speichern“: the booking line, warnings about the
* pack (expired, too little) that never block, and the temperature hint. The
* region stays mounted so a change is announced.
*/
export function BookingNotes({
choice,
doseQuantity,
colonyCount,
treatmentDay,
apiaryId,
canReadApiaries,
}: BookingNotesProps) {
const dose = parsedDose(doseQuantity);
const { lot, medicine } = choice;
const line = bookingLine(dose, lot ? lot.unit : null, colonyCount);
const warnings = lot ? lotWarnings(lot, dose, colonyCount, treatmentDay) : [];
return (
<div role="status" aria-live="polite" className="flex flex-col gap-1 empty:hidden">
{line ? (
<p className="flex items-start gap-2 text-sm">
<PackageMinus className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{line}</span>
</p>
) : null}
{warnings.map((warning) => (
<p key={warning} className="flex items-start gap-2 text-sm font-medium">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>{warning}</span>
</p>
))}
{warnings.length > 0 ? (
<p className="text-xs text-muted-foreground">Speichern ist trotzdem möglich.</p>
) : null}
<TemperatureNote
medicine={medicine}
apiaryId={apiaryId}
canReadApiaries={canReadApiaries}
/>
</div>
);
}
function TemperatureNote({
medicine,
apiaryId,
canReadApiaries,
}: {
medicine: MedicineView | undefined;
apiaryId: string | null;
canReadApiaries: boolean;
}) {
const hasWindow = medicine !== undefined &&
(medicine.minTemperatureC !== null || medicine.maxTemperatureC !== null);
const weather = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId ?? ""),
enabled: canReadApiaries && apiaryId !== null && hasWindow,
retry: false,
});
if (!medicine || !hasWindow || !weather.data) return null;
const day = todayLocalIsoDate();
const forecast = weather.data.daily.find((entry) => toCalendarDay(entry.date) === day);
if (!forecast) return null;
const hint = temperatureHint(
{ min: forecast.tempMinC, max: forecast.tempMaxC },
{ min: medicine.minTemperatureC, max: medicine.maxTemperatureC },
);
if (!hint) return null;
const Icon = hint.warn ? AlertTriangle : Thermometer;
return (
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden />
<span>{hint.text}</span>
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
</p>
);
}