feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
- 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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user