feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
Backend Image / build-and-push (push) Successful in 6m45s
Frontend Image / build-and-push (push) Failing after 9m30s

- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
  harvest withdrawal), medicine subjects masked without
  veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
  409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
  nav; veterinary medicine catalogue, lots, movement book with inventory
  and disposal; label designer check panel, print card with gap list and
  proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
  on every answer); compliance, medicine, restriction, notice and label
  check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.

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 15:59:08 +02:00
1 parent b912d29166
commit b4746a2b17
149 files changed
+11869 -320

No files matched your search

@@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import ApiaryDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(),
@@ -13,7 +15,24 @@ vi.mock("@/lib/export/api", () => ({
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/detail-header", () => ({
DetailHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
DetailHeader: ({
title,
moreActions,
}: {
title: string;
moreActions?: { label: string; disabled?: boolean }[];
}) => (
<header>
<h1>{title}</h1>
<ul>
{moreActions?.map((action) => (
<li key={action.label} data-disabled={action.disabled ? "true" : undefined}>
{action.label}
</li>
))}
</ul>
</header>
),
DetailQueryFallback: () => <p>Lädt</p>,
DetailStatus: () => null,
}));
@@ -29,24 +48,60 @@ vi.mock("@/components/phenology/apiary-season", () => ({
),
}));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
vi.mocked(fetchApiary).mockResolvedValue({
id: "a-1",
name: "Obstwiese",
location: null,
status: "ACTIVE",
latitude: null,
longitude: null,
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ApiaryDetailPage />
</QueryClientProvider>,
);
}
describe("ApiaryDetailPage", () => {
it("shows the season of the apiary below the header", async () => {
vi.mocked(fetchApiary).mockResolvedValue({
id: "a-1",
name: "Obstwiese",
location: null,
status: "ACTIVE",
latitude: null,
longitude: null,
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ApiaryDetailPage />
</QueryClientProvider>,
);
renderPage(["veterinary-medicines:read"]);
expect(await screen.findByText("Saison von Obstwiese")).toBeInTheDocument();
});
it("offers the Bestandsbuch with veterinary-medicines:read", async () => {
renderPage(["veterinary-medicines:read"]);
const item = await screen.findByText(/^Bestandsbuch \d{4} \(PDF\)$/);
expect(item).not.toHaveAttribute("data-disabled");
});
it("switches the Bestandsbuch off without veterinary-medicines:read and says why", async () => {
renderPage([]);
const item = await screen.findByText(/^Bestandsbuch \d{4} \(PDF\) – nur mit dem Recht Tierarzneimittel lesen$/);
expect(item).toHaveAttribute("data-disabled", "true");
});
it("keeps a visible hint about the missing right next to the switched-off Bestandsbuch", async () => {
renderPage([]);
expect(
await screen.findByText("Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“."),
).toBeInTheDocument();
});
it("shows no hint with the right", async () => {
renderPage(["veterinary-medicines:read"]);
await screen.findByText("Saison von Obstwiese");
expect(screen.queryByText(/Das Bestandsbuch braucht das Recht/)).not.toBeInTheDocument();
});
});
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { useAuth } from "@/lib/auth/auth-context";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -19,6 +20,7 @@ const BACK = { backHref: "/apiaries", backLabel: "Alle Bienenstände" };
export default function ApiaryDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const apiaryQuery = useQuery({
queryKey: ["apiaries", params.id],
queryFn: () => fetchApiary(params.id),
@@ -39,6 +41,8 @@ export default function ApiaryDetailPage() {
return <DetailQueryFallback query={apiaryQuery} what="Standdaten" {...BACK} />;
}
const canReadMedicines = hasPermission("veterinary-medicines:read");
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
@@ -59,11 +63,14 @@ export default function ApiaryDetailPage() {
href: `/harvests/new?apiaryId=${apiary.id}`,
},
{
label: treatmentLogMutation.isPending
? "Bestandsbuch wird erstellt…"
: `Bestandsbuch ${CURRENT_YEAR} (PDF)`,
// The book shows supplier, proof and vet from the lot (needs the medicines right).
label: !canReadMedicines
? `Bestandsbuch ${CURRENT_YEAR} (PDF) – nur mit dem Recht Tierarzneimittel lesen`
: treatmentLogMutation.isPending
? "Bestandsbuch wird erstellt…"
: `Bestandsbuch ${CURRENT_YEAR} (PDF)`,
icon: FileText,
disabled: treatmentLogMutation.isPending,
disabled: !canReadMedicines || treatmentLogMutation.isPending,
onSelect: () => treatmentLogMutation.mutate(),
},
{
@@ -77,6 +84,11 @@ export default function ApiaryDetailPage() {
]}
{...BACK}
/>
{!canReadMedicines ? (
<p className="text-sm text-muted-foreground">
Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.
</p>
) : null}
<ApiarySeason apiary={{ id: apiary.id, name: apiary.name }} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
@@ -86,7 +86,7 @@ export default function BottlingBatchDetailPage() {
unit="Gläser"
canBook={hasPermission("bottling:withdraw")}
/>
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} productId={bottlingBatch.productId} />
</div>
</main>
);
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { downloadBlob } from "@/lib/export/api";
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
import { checkTemplateForBottling, type LabelCheckResult } from "@/lib/label-templates/check";
import type { LabelTemplate } from "@/lib/label-templates/types";
import { PrintLabelsCard } from "./print-labels-card";
const permissions = new Set(["label-templates:read", "label-templates:print", "bottling:read"]);
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
vi.mock("@/lib/label-templates/api", () => ({
fetchLabelTemplates: vi.fn(),
printLabels: vi.fn(),
}));
vi.mock("@/lib/label-templates/check", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/label-templates/check")>()),
checkTemplateForBottling: vi.fn(),
}));
const TEMPLATE = {
id: "tpl-1",
name: "Standard",
active: true,
isDefault: true,
columns: 2,
rows: 5,
} as LabelTemplate;
const COMPLETE: LabelCheckResult = {
printable: true,
gaps: [],
warnings: [],
sample: false,
requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: null },
};
const GAPS: LabelCheckResult = {
...COMPLETE,
printable: false,
gaps: [
{ code: "FOOD_BUSINESS", title: "Lebensmittelunternehmer fehlt", detail: "Name und Anschrift fehlen.", elementIds: [] },
{ code: "LEGAL_NAME", title: "Verkehrsbezeichnung fehlt", detail: "Kein Wort mit Honig.", elementIds: [] },
{ code: "NET_QUANTITY", title: "Füllmenge fehlt", detail: "Die Füllmenge fehlt.", elementIds: [] },
],
};
function renderCard(props: { productId?: string | null } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PrintLabelsCard bottlingBatchId="b-1" productId={props.productId === undefined ? "p-9" : props.productId} />
</QueryClientProvider>,
);
}
beforeEach(() => {
permissions.clear();
["label-templates:read", "label-templates:print", "bottling:read"].forEach((p) => permissions.add(p));
vi.mocked(fetchLabelTemplates).mockReset().mockResolvedValue([TEMPLATE]);
vi.mocked(checkTemplateForBottling).mockReset().mockResolvedValue(COMPLETE);
vi.mocked(printLabels).mockReset().mockResolvedValue(new Blob(["pdf"]));
vi.mocked(downloadBlob).mockReset();
act(() => reportRequestOutcome("reached-server"));
});
afterEach(() => act(() => reportRequestOutcome("reached-server")));
describe("PrintLabelsCard", () => {
it("the main button is aria-disabled with the reason in aria-describedby while a particular is missing, and stays focusable", async () => {
vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).toHaveAttribute("aria-disabled", "true"));
expect(main).not.toBeDisabled();
const reason = document.getElementById(main.getAttribute("aria-describedby") ?? "");
expect(reason).toHaveTextContent(/Pflichtangaben fehlen/);
main.focus();
expect(main).toHaveFocus();
await userEvent.click(main);
expect(printLabels).not.toHaveBeenCalled();
});
it("shows the red gap list with links to Stammdaten, Produkt and Designer", async () => {
vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
renderCard();
const list = await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
const link = (name: RegExp) => screen.getAllByRole("link", { name });
expect(link(/^Stammdaten öffnen/)[0]).toHaveAttribute(
"href",
"/settings/automation?tab=social-media",
);
expect(link(/^Produkt öffnen/)[0]).toHaveAttribute("href", "/products/p-9");
expect(link(/^Designer öffnen/)[0]).toHaveAttribute("href", "/settings/label-templates/tpl-1");
expect(list).toHaveTextContent("Fehlt: Name und Anschrift des Lebensmittelunternehmers");
expect(list).toHaveTextContent("Name und Anschrift fehlen.");
expect(list).toHaveTextContent("Fehlt");
expect(list.querySelector("svg")).not.toBeNull();
});
it("leaves out the product link when the bottling has no product", async () => {
vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
renderCard({ productId: null });
await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
expect(screen.queryByRole("link", { name: /^Produkt öffnen/ })).not.toBeInTheDocument();
});
it("Probedruck (MUSTER) stays possible and requests proof=true", async () => {
vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
renderCard();
const proof = await screen.findByRole("button", { name: "Probedruck (MUSTER)" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Etiketten als PDF" })).toHaveAttribute("aria-disabled", "true"),
);
expect(proof).not.toHaveAttribute("aria-disabled", "true");
await userEvent.click(proof);
await waitFor(() =>
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: undefined, proof: true }),
);
await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
});
it("moves focus to the gap list after a 409 LABEL_PARTICULARS_MISSING", async () => {
// The check was stale and said "complete"; the server knows better.
vi.mocked(printLabels).mockRejectedValue(
new ApiError(409, "Es fehlen Pflichtangaben.", {
code: "LABEL_PARTICULARS_MISSING",
message: "Es fehlen Pflichtangaben.",
gaps: GAPS.gaps,
}),
);
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
const list = await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
await waitFor(() => expect(list).toHaveFocus());
expect(downloadBlob).not.toHaveBeenCalled();
});
it("explains invalid compliance settings in German after a 409 COMPLIANCE_SETTINGS_INVALID", async () => {
vi.mocked(printLabels).mockRejectedValue(
new ApiError(409, "Compliance-Einstellungen fehlen oder sind ungültig.", {
code: "COMPLIANCE_SETTINGS_INVALID",
message: "Compliance-Einstellungen fehlen oder sind ungültig.",
}),
);
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
expect(await screen.findByRole("alert")).toHaveTextContent(/Compliance-Einstellungen/);
});
it("prints the PDF when the check has no gaps; warnings do not block", async () => {
vi.mocked(checkTemplateForBottling).mockResolvedValue({
...COMPLETE,
warnings: [{ code: "X_HEIGHT", title: "Text zu klein (x-Höhe 1,1 mm)", detail: "", elementIds: [] }],
});
renderCard();
expect(await screen.findByText("Text zu klein (x-Höhe 1,1 mm)")).toBeInTheDocument();
const main = screen.getByRole("button", { name: "Etiketten als PDF" });
expect(main).not.toHaveAttribute("aria-disabled", "true");
await userEvent.click(main);
await waitFor(() =>
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: undefined, proof: false }),
);
await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("region", { name: /Pflichtangaben fehlen/ })).not.toBeInTheDocument();
});
it("names the proof print file with MUSTER and the real one without", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Probedruck (MUSTER)" }));
await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
expect(vi.mocked(downloadBlob).mock.calls[0][1]).toMatch(/muster/i);
const main = screen.getByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(2));
expect(vi.mocked(downloadBlob).mock.calls[1][1]).not.toMatch(/muster/i);
});
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)));
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
await userEvent.click(main);
await userEvent.click(screen.getByRole("button", { name: "Probedruck (MUSTER)" }));
expect(printLabels).toHaveBeenCalledTimes(1);
await act(async () => finish(new Blob(["pdf"])));
await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
});
it("sends the start position when it is above 1", async () => {
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
const input = screen.getByLabelText(/Startposition/);
fireEvent.change(input, { target: { value: "4" } });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
await waitFor(() =>
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 4, proof: false }),
);
});
it("keeps the print button inactive while the check runs", async () => {
vi.mocked(checkTemplateForBottling).mockReturnValue(new Promise(() => {}));
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
expect(main).toHaveAttribute("aria-disabled", "true");
expect(document.getElementById(main.getAttribute("aria-describedby") ?? "")).toHaveTextContent(/geprüft/);
});
it("is online only: both buttons are inactive offline with the visible reason", async () => {
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
act(() => reportRequestOutcome("network-error"));
expect(main).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Probedruck (MUSTER)" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
});
it("handles a missing permission gracefully without asking the server", async () => {
permissions.delete("label-templates:read");
renderCard();
expect(await screen.findByText(/Berechtigung/)).toBeInTheDocument();
expect(fetchLabelTemplates).not.toHaveBeenCalled();
expect(checkTemplateForBottling).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Etiketten als PDF" })).not.toBeInTheDocument();
});
it("shows a 403 of the check as a German message", async () => {
vi.mocked(checkTemplateForBottling).mockRejectedValue(new ApiError(403, "Forbidden"));
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
});
});
@@ -1,40 +1,252 @@
"use client";
import { useState } from "react";
import { Printer, Tag } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import Link from "next/link";
import { CircleX, Printer, ShieldAlert, Tag, TriangleAlert } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
import {
checkTemplateForBottling,
labelCheckErrorText,
labelParticularsMissing,
type LabelFinding,
} from "@/lib/label-templates/check";
import { downloadBlob } from "@/lib/export/api";
import { useAuth } from "@/lib/auth/auth-context";
import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RecordSelect } from "@/components/form/record-select";
export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
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";
type FixTarget = "master" | "product" | "designer";
// Where a person fixes a gap: master data (Verwaltung > Automatisierung), the
// product of the bottling, or the template itself.
const FIX_TARGETS: Record<string, readonly FixTarget[]> = {
LEGAL_NAME: ["product", "designer"],
NET_QUANTITY: ["designer"],
BEST_BEFORE: ["designer"],
LOT: ["designer"],
ORIGIN: ["master", "designer"],
FOOD_BUSINESS: ["master", "designer"],
};
interface ServerGaps {
templateId: string;
/** When the server answered; a later check result wins. */
at: number;
gaps: LabelFinding[];
}
function gapName(gap: LabelFinding): string {
return PARTICULAR_LABELS[gap.code as keyof typeof PARTICULAR_LABELS] ?? gap.title;
}
function fixLinks(
gap: LabelFinding,
templateId: string,
productId: string | null | undefined,
): { label: string; href: string }[] {
const targets = FIX_TARGETS[gap.code] ?? ["designer"];
return targets.flatMap((target): { label: string; href: string }[] => {
if (target === "master") {
return [{ label: "Stammdaten öffnen", href: "/settings/automation?tab=social-media" }];
}
if (target === "product") {
return productId
? [{ label: "Produkt öffnen", href: `/products/${encodeURIComponent(productId)}` }]
: [];
}
return [
{
label: "Designer öffnen",
href: `/settings/label-templates/${encodeURIComponent(templateId)}`,
},
];
});
}
function GapList({
gaps,
templateId,
productId,
sectionRef,
}: {
gaps: LabelFinding[];
templateId: string;
productId: string | null | undefined;
sectionRef: React.RefObject<HTMLElement | null>;
}) {
const headingId = useId();
return (
<section
ref={sectionRef}
tabIndex={-1}
aria-labelledby={headingId}
className="flex flex-col gap-2 rounded-xl border border-destructive/50 p-3 focus:outline-2 focus:outline-ring"
>
<h3 id={headingId} className="flex items-center gap-2 text-sm font-semibold text-destructive">
<ShieldAlert aria-hidden className="size-4" />
Pflichtangaben fehlen
</h3>
<ul className="flex flex-col gap-3">
{gaps.map((gap, index) => (
<li key={`${gap.code}-${index}`} className="flex items-start gap-2 text-sm">
<CircleX aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
<span className="flex flex-col">
<span>
<span className="font-semibold text-destructive">Fehlt:</span> {gapName(gap)}
</span>
<span className="text-muted-foreground">{gap.detail || gap.title}</span>
<span className="flex flex-wrap gap-x-4">
{fixLinks(gap, templateId, productId).map((link) => (
<Link key={link.href} href={link.href} className={LINK_CLASS}>
{link.label}
<span className="sr-only"> für {gapName(gap)}</span>
</Link>
))}
</span>
</span>
</li>
))}
</ul>
</section>
);
}
export function PrintLabelsCard({
bottlingBatchId,
productId,
}: {
bottlingBatchId: string;
/** Product of the bottling; the legal name of the label is kept there. */
productId?: string | null;
}) {
const { hasPermission } = useAuth();
const canReadTemplates = hasPermission("label-templates:read");
const canPrint = hasPermission("label-templates:print");
const canCheck = canReadTemplates && hasPermission("bottling:read");
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
const reasonId = useId();
const [templateId, setTemplateId] = useState("");
const [startPosition, setStartPosition] = useState(1);
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
const [printError, setPrintError] = useState<string | null>(null);
const gapsRef = useRef<HTMLElement>(null);
const focusGapsNext = useRef(false);
const { data: templates } = useQuery({
queryKey: ["label-templates"],
queryFn: fetchLabelTemplates,
enabled: canReadTemplates,
});
const activeTemplates = (templates ?? []).filter((template) => template.active);
const preferredTemplateId =
activeTemplates.find((template) => template.isDefault)?.id ?? activeTemplates[0]?.id ?? "";
const effectiveTemplateId = templateId || preferredTemplateId;
const selectedTemplate = activeTemplates.find(
(template) => template.id === effectiveTemplateId,
);
const selectedTemplate = activeTemplates.find((template) => template.id === effectiveTemplateId);
const slotsPerSheet =
selectedTemplate?.columns && selectedTemplate?.rows
? selectedTemplate.columns * selectedTemplate.rows
: undefined;
const mutation = useMutation({
mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId, startPosition),
onError: () => toast.error("Etiketten konnten nicht erstellt werden"),
// The answer holds the food business address: no persistence, dropped as
// soon as nobody looks at it any more.
const check = useQuery({
queryKey: ["label-templates", "check", effectiveTemplateId, bottlingBatchId],
queryFn: () => checkTemplateForBottling(effectiveTemplateId, bottlingBatchId),
enabled: canCheck && effectiveTemplateId !== "",
gcTime: 0,
retry: false,
});
const gaps =
serverGaps && serverGaps.templateId === effectiveTemplateId && serverGaps.at >= check.dataUpdatedAt
? serverGaps.gaps
: (check.data?.gaps ?? []);
const warnings = check.data?.warnings ?? [];
const isChecking = check.isFetching && check.data === undefined;
useEffect(() => {
if (focusGapsNext.current && gapsRef.current) {
focusGapsNext.current = false;
gapsRef.current.focus();
}
}, [serverGaps]);
const mutation = useMutation({
mutationFn: (proof: boolean) =>
printLabels(effectiveTemplateId, bottlingBatchId, {
startPosition: startPosition > 1 ? startPosition : undefined,
proof,
}),
onSuccess: (blob, proof) => {
setServerGaps(null);
setPrintError(null);
downloadBlob(blob, proof ? "etiketten-muster.pdf" : "etiketten.pdf");
},
onError: (cause) => {
const missing = labelParticularsMissing(cause);
if (missing) {
// The server knows better than the last check: show its list and go there.
setPrintError(null);
focusGapsNext.current = true;
setServerGaps({ templateId: effectiveTemplateId, at: Date.now(), gaps: missing.gaps });
return;
}
setPrintError(labelCheckErrorText(cause, PRINT_FALLBACK));
},
});
if (!canReadTemplates) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="size-4.5" />
Etiketten drucken
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Dir fehlt die Berechtigung für Etiketten. Wende dich an die Administration.
</p>
</CardContent>
</Card>
);
}
const blockReason =
gaps.length > 0
? `${gaps.length === 1 ? "1 Pflichtangabe fehlt" : `${gaps.length} Pflichtangaben fehlen`}. Behebe sie, dann kannst du drucken. Der Probedruck geht trotzdem.`
: isChecking
? "Die Pflichtangaben werden noch geprüft."
: null;
const idle = !effectiveTemplateId || mutation.isPending || isOffline;
const mainInactive = idle || blockReason !== null;
const mainDescribedBy =
[blockReason ? reasonId : undefined, offlineDescribedBy].filter(Boolean).join(" ") || undefined;
const summary = !canCheck || !effectiveTemplateId
? null
: isChecking
? "Die Pflichtangaben werden geprüft …"
: check.data || serverGaps
? gaps.length === 0
? "Alle Pflichtangaben sind erfüllt."
: gaps.length === 1
? "Es fehlt 1 Pflichtangabe."
: `Es fehlen ${gaps.length} Pflichtangaben.`
: null;
return (
<Card>
<CardHeader>
@@ -59,6 +271,37 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
getLabel={(template) => template.name}
placeholder="Vorlage wählen"
/>
<p aria-live="polite" className="text-sm font-medium">
{summary}
</p>
{check.isError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{labelCheckErrorText(check.error, CHECK_FALLBACK)}
</p>
) : null}
{gaps.length > 0 ? (
<GapList
gaps={gaps}
templateId={effectiveTemplateId}
productId={productId}
sectionRef={gapsRef}
/>
) : null}
{warnings.length > 0 ? (
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Hinweise (sie sperren den Druck nicht)</p>
<ul className="flex flex-col gap-1">
{warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
<span>
<span className="font-medium">Hinweis:</span> {warning.title}
</span>
</li>
))}
</ul>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="print-start-position">
Startposition auf dem Bogen (1 = oben links)
@@ -76,14 +319,53 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
</p>
</div>
<Button
type="button"
disabled={!effectiveTemplateId || mutation.isPending}
onClick={() => mutation.mutate()}
>
<Printer className="size-4" />
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
</Button>
{canPrint ? (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="touch"
aria-disabled={mainInactive ? true : undefined}
aria-describedby={mainDescribedBy}
className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
onClick={() => {
if (!mainInactive) mutation.mutate(false);
}}
>
<Printer className="size-4" />
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
</Button>
<Button
type="button"
variant="outline"
size="touch"
aria-disabled={idle ? true : undefined}
aria-describedby={offlineDescribedBy}
className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
onClick={() => {
if (!idle) mutation.mutate(true);
}}
>
Probedruck (MUSTER)
</Button>
</div>
{blockReason ? (
<p id={reasonId} className="text-sm text-muted-foreground">
{blockReason}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
{printError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{printError}
</p>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">
Dir fehlt die Berechtigung zum Drucken.
</p>
)}
</>
)}
</CardContent>
@@ -0,0 +1,147 @@
import { beforeEach, 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 { AcknowledgementActions } from "./acknowledge-dialog";
import { acknowledge, revokeAcknowledgement } from "@/lib/compliance/api";
import { itemFixture } from "@/lib/compliance/fixtures";
import { ApiError } from "@/lib/auth/api-client";
let connectivity = "online";
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
vi.mock("@/lib/compliance/api", () => ({
acknowledge: vi.fn(),
revokeAcknowledgement: vi.fn(),
}));
const onDone = vi.fn();
function renderActions(item = itemFixture({ acknowledgeable: true })) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<AcknowledgementActions item={item} onDone={onDone} />
</QueryClientProvider>,
);
return { invalidate };
}
describe("AcknowledgementActions", () => {
beforeEach(() => {
connectivity = "online";
onDone.mockReset();
vi.mocked(acknowledge).mockReset();
vi.mocked(revokeAcknowledgement).mockReset();
});
it("asks for a note of at least 10 characters before it submits", async () => {
vi.mocked(acknowledge).mockResolvedValue(undefined);
const item = itemFixture({ acknowledgeable: true, key: "k", subjectId: "s", fingerprint: "fp" });
renderActions(item);
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
const note = await screen.findByLabelText(/Notiz/);
await userEvent.type(note, "zu kurz");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
expect(acknowledge).not.toHaveBeenCalled();
expect(await screen.findByText("Die Notiz braucht mindestens 10 Zeichen.")).toBeInTheDocument();
await userEvent.type(note, " – jetzt lang genug");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
expect(acknowledge).toHaveBeenCalledWith({
itemKey: "k",
subjectId: "s",
fingerprint: "fp",
note: "zu kurz – jetzt lang genug",
});
});
it("offers Quittieren only for acknowledgeable items and Zurücknehmen for acknowledged ones", async () => {
renderActions(itemFixture({ acknowledgeable: false }));
expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Zurücknehmen" })).not.toBeInTheDocument();
});
it("revokes an acknowledged item after a confirmation", async () => {
vi.mocked(revokeAcknowledgement).mockResolvedValue(undefined);
const { invalidate } = renderActions(
itemFixture({
acknowledgeable: true,
state: "ACKNOWLEDGED",
acknowledgement: { id: "ack-1", note: "Notiz Notiz", userName: "Nick", createdAt: "2026-10-01T00:00:00.000Z" },
}),
);
expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Zurücknehmen" }));
await userEvent.click(await screen.findByRole("button", { name: "Zurücknehmen bestätigen" }));
expect(revokeAcknowledgement).toHaveBeenCalledWith("ack-1");
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["compliance"] }));
});
it("shows the server message on a 409 stale fingerprint and reloads the report", async () => {
vi.mocked(acknowledge).mockRejectedValue(
new ApiError(409, "Der Punkt hat sich geändert. Bitte neu laden.", {
statusCode: 409,
message: "Der Punkt hat sich geändert. Bitte neu laden.",
}),
);
const { invalidate } = renderActions();
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Der Punkt hat sich geändert. Bitte neu laden.");
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["compliance"] });
});
it("is off without a connection and says why", () => {
connectivity = "offline";
renderActions();
const trigger = screen.getByRole("button", { name: "Quittieren" });
expect(trigger).toBeDisabled();
expect(trigger).toHaveAccessibleDescription("Nur mit Verbindung");
});
it("shows a distinct text per conflict code", async () => {
vi.mocked(acknowledge).mockRejectedValue(
new ApiError(409, "x", { statusCode: 409, code: "READINESS_ITEM_NOT_OPEN", message: "x" }),
);
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("nicht mehr offen");
expect(onDone).not.toHaveBeenCalled();
});
it("reports what happened once the change is saved", async () => {
vi.mocked(acknowledge).mockResolvedValue(undefined);
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
await vi.waitFor(() => expect(onDone).toHaveBeenCalledWith("Punkt quittiert"));
});
it("reports the revoke", async () => {
vi.mocked(revokeAcknowledgement).mockResolvedValue(undefined);
renderActions(
itemFixture({
state: "ACKNOWLEDGED",
acknowledgement: { id: "ack-1", note: "Notiz Notiz", userName: "Nick", createdAt: "2026-10-01T00:00:00.000Z" },
}),
);
await userEvent.click(screen.getByRole("button", { name: "Zurücknehmen" }));
await userEvent.click(await screen.findByRole("button", { name: "Zurücknehmen bestätigen" }));
await vi.waitFor(() => expect(onDone).toHaveBeenCalledWith("Quittung zurückgenommen"));
});
});
@@ -0,0 +1,225 @@
"use client";
import { FormEvent, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { acknowledge, revokeAcknowledgement } from "@/lib/compliance/api";
import { complianceErrorText, readinessConflictText } from "@/lib/compliance/errors";
import { complianceKey } from "@/lib/compliance/use-readiness";
import type { ReadinessItem } from "@/lib/compliance/types";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
const MIN_NOTE_LENGTH = 10;
const MAX_NOTE_LENGTH = 2000;
const ACKNOWLEDGED_MESSAGE = "Punkt quittiert";
const REVOKED_MESSAGE = "Quittung zurückgenommen";
const SHORT_NOTE_ERROR = `Die Notiz braucht mindestens ${MIN_NOTE_LENGTH} Zeichen.`;
/** Reloads every compliance query; after a 409 the item may look different. */
function useReloadCompliance(): () => Promise<void> {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: complianceKey });
}
function isConflict(error: unknown): boolean {
return error instanceof ApiError && error.status === 409;
}
interface AcknowledgeDialogProps {
item: ReadinessItem;
disabled: boolean;
describedBy: string | undefined;
onDone: (message: string) => void;
}
function AcknowledgeDialog({ item, disabled, describedBy, onDone }: AcknowledgeDialogProps) {
const reload = useReloadCompliance();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [noteError, setNoteError] = useState<string | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const noteRef = useRef<HTMLTextAreaElement>(null);
const mutation = useMutation({
mutationFn: () =>
acknowledge({
itemKey: item.key,
subjectId: item.subjectId,
fingerprint: item.fingerprint,
note: note.trim(),
}),
onSuccess: async () => {
setOpen(false);
setNote("");
await reload();
onDone(ACKNOWLEDGED_MESSAGE);
},
onError: (error) => {
setServerError(readinessConflictText(error) ?? complianceErrorText(error, "Das Quittieren hat nicht geklappt."));
if (isConflict(error)) void reload();
},
});
function handleOpenChange(next: boolean) {
setOpen(next);
if (!next) {
setNoteError(null);
setServerError(null);
}
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
if (note.trim().length < MIN_NOTE_LENGTH) {
setNoteError(SHORT_NOTE_ERROR);
noteRef.current?.focus();
return;
}
setNoteError(null);
setServerError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Button
type="button"
variant="outline"
size="touch"
disabled={disabled}
aria-describedby={describedBy}
onClick={() => setOpen(true)}
>
Quittieren
</Button>
<DialogContent>
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
<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.
</DialogDescription>
</DialogHeader>
<Field label="Notiz" required hint={`Mindestens ${MIN_NOTE_LENGTH} Zeichen.`} error={noteError}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Textarea
id={id}
ref={noteRef}
value={note}
maxLength={MAX_NOTE_LENGTH}
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
onChange={(event) => setNote(event.target.value)}
/>
)}
</Field>
{serverError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{serverError}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" size="touch" onClick={() => handleOpenChange(false)}>
Abbrechen
</Button>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Quittieren bestätigen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
interface RevokeButtonProps {
acknowledgementId: string;
disabled: boolean;
describedBy: string | undefined;
onError: (message: string | null) => void;
onDone: (message: string) => void;
}
function RevokeButton({ acknowledgementId, disabled, describedBy, onError, onDone }: RevokeButtonProps) {
const reload = useReloadCompliance();
const mutation = useMutation({
mutationFn: () => revokeAcknowledgement(acknowledgementId),
onSuccess: async () => {
onError(null);
await reload();
onDone(REVOKED_MESSAGE);
},
onError: (error) => {
onError(readinessConflictText(error) ?? complianceErrorText(error, "Das Zurücknehmen hat nicht geklappt."));
if (isConflict(error)) void reload();
},
});
return (
<ConfirmDialog
triggerLabel="Zurücknehmen"
triggerSize="touch"
triggerDisabled={disabled}
triggerDescribedBy={describedBy}
title="Quittung zurücknehmen?"
description="Der Punkt ist danach wieder offen."
confirmLabel="Zurücknehmen bestätigen"
destructive={false}
isPending={mutation.isPending}
onConfirm={() => mutation.mutate()}
/>
);
}
/**
* Quittieren for an open item that allows it, Zurücknehmen for an acknowledged
* one. Both need the server, so offline they are off with the visible reason.
*/
export function AcknowledgementActions({
item,
onDone,
}: {
item: ReadinessItem;
/** Called with the text to announce once the change is saved and the report reloaded. */
onDone: (message: string) => void;
}) {
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [revokeError, setRevokeError] = useState<string | null>(null);
const canAcknowledge = item.state === "OPEN" && item.acknowledgeable;
const acknowledgementId = item.state === "ACKNOWLEDGED" ? item.acknowledgement?.id : undefined;
if (!canAcknowledge && !acknowledgementId) return null;
return (
<div className="flex flex-col gap-1">
{canAcknowledge ? (
<AcknowledgeDialog item={item} disabled={isOffline} describedBy={describedBy} onDone={onDone} />
) : null}
{acknowledgementId ? (
<RevokeButton
acknowledgementId={acknowledgementId}
disabled={isOffline}
describedBy={describedBy}
onError={setRevokeError}
onDone={onDone}
/>
) : null}
<OnlineOnlyHint id={hintId} />
<div aria-live="assertive">
{revokeError ? <p className="text-sm font-medium text-destructive">{revokeError}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CompliancePage from "./page";
import { downloadReadinessReport, fetchReadiness } from "@/lib/compliance/api";
import { itemFixture, reportFixture } from "@/lib/compliance/fixtures";
import { exportTreatmentLogPdf } from "@/lib/export/api";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/api", () => ({
fetchReadiness: vi.fn(),
downloadReadinessReport: vi.fn(),
acknowledge: vi.fn(),
revokeAcknowledgement: vi.fn(),
}));
vi.mock("@/lib/export/api", () => ({ exportTreatmentLogPdf: vi.fn() }));
const open = (overrides = {}) => itemFixture({ state: "OPEN", ...overrides });
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CompliancePage />
</QueryClientProvider>,
);
}
describe("CompliancePage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchReadiness).mockReset();
vi.mocked(exportTreatmentLogPdf).mockReset();
vi.mocked(downloadReadinessReport).mockReset();
});
it("shows Prüfbereit in green when nothing is open and offers Bestandsbuch drucken as the main action", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([itemFixture({ state: "DONE", title: "Angaben vollständig" })]),
);
renderPage();
expect(await screen.findByText("Prüfbereit")).toBeInTheDocument();
const year = new Date().getFullYear();
const main = screen.getByRole("button", { name: `Bestandsbuch ${year} drucken` });
await userEvent.click(main);
expect(exportTreatmentLogPdf).toHaveBeenCalledWith(year);
expect(screen.queryByRole("link", { name: "Nächsten Punkt lösen" })).not.toBeInTheDocument();
});
it('shows "3 offen" in red with the symbol and the word Offen for every open item', async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({ key: "a", title: "Punkt A" }),
open({ key: "b", title: "Punkt B" }),
open({ key: "c", title: "Punkt C" }),
]),
);
renderPage();
const badge = await screen.findByText("3 offen");
expect(badge.closest("[data-slot=badge]")).toHaveClass("text-destructive");
expect(badge.parentElement?.querySelector("svg")).not.toBeNull();
const items = screen.getAllByRole("listitem").filter((li) => li.hasAttribute("data-item-key"));
expect(items).toHaveLength(3);
for (const item of items) {
expect(within(item).getByText("Offen")).toBeInTheDocument();
expect(item.querySelector("svg")).not.toBeNull();
expect(item).toHaveClass("border-destructive/40");
}
});
it("the main action jumps to the first open PFLICHT item", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
open({ key: "holder-data", severity: "PFLICHT", subjects: [] }),
]),
);
renderPage();
const main = await screen.findByRole("link", { name: "Nächsten Punkt lösen" });
expect(main).toHaveAttribute("href", "/settings/automation?tab=social-media");
});
it("shows the severity as a word, Pflicht or Prüfen, next to the title", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({ key: "holder-data", severity: "PFLICHT", title: "Bienenhalter fehlt" }),
open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
]),
);
renderPage();
const holder = (await screen.findByRole("heading", { name: "Bienenhalter fehlt" })).closest("li");
const vet = screen.getByRole("heading", { name: "Veterinäramt fehlt" }).closest("li");
expect(within(holder as HTMLElement).getByText("Pflicht")).toBeInTheDocument();
expect(within(vet as HTMLElement).getByText("Prüfen")).toBeInTheDocument();
});
it("lists affected records collapsed with a Beheben link each", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({
key: "treatment-log-gaps",
area: "TIERARZNEIMITTEL",
title: "Lücken im Bestandsbuch",
subjects: [
{ type: "treatment", id: "t1", parentId: "c1", label: "Behandlung 1", missing: ["Beleg"] },
{ type: "lot", id: "l1", label: "Packung 1" },
],
}),
]),
);
renderPage();
const summary = await screen.findByText("2 betroffen");
expect(summary.closest("details")).not.toHaveAttribute("open");
const links = screen.getAllByRole("link", { name: /Beheben/, hidden: true });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/colonies/c1",
"/medicines/lots/l1",
]);
expect(screen.getByText(/Beleg/)).toBeInTheDocument();
});
it("shows Erledigt (n) collapsed", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({ key: "a" }),
itemFixture({ key: "b", state: "DONE", title: "Fertig B" }),
itemFixture({ key: "c", state: "DONE", title: "Fertig C" }),
]),
);
renderPage();
const summary = await screen.findByText("Erledigt (2)");
expect(summary.closest("details")).not.toHaveAttribute("open");
});
it("shows the more-count when subjects are cut off", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([
open({
key: "harvest-withdrawal",
area: "ERNTE",
subjects: [{ type: "harvest", id: "h1", label: "Ernte 1" }],
more: 7,
}),
]),
);
renderPage();
expect(await screen.findByText("und 7 weitere")).toBeInTheDocument();
});
it("says why nothing can be shown when the report fails to load", async () => {
vi.mocked(fetchReadiness).mockRejectedValue(new Error("boom"));
renderPage();
expect(await screen.findByRole("button", { name: /Erneut versuchen/ })).toBeInTheDocument();
});
it("falls back to showing the item when the page that fixes it does not exist yet", async () => {
vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([open({ key: "vet-office", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
);
renderPage();
const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" });
expect(main).toHaveAttribute("href", "#item-vet-office-global");
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
BookOpen,
CheckCircle2,
FileText,
Printer,
Settings,
ShieldCheck,
TriangleAlert,
XCircle,
} from "lucide-react";
import { DetailHeader, type MoreAction } from "@/components/detail-header";
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { downloadReadinessReport } from "@/lib/compliance/api";
import { complianceErrorText } from "@/lib/compliance/errors";
import { fixLinkFor } from "@/lib/compliance/fix-links";
import { AREA_LABEL, AREA_ORDER } from "@/lib/compliance/labels";
import type { ReadinessItem, ReadinessReport } from "@/lib/compliance/types";
import { useReadiness } from "@/lib/compliance/use-readiness";
import { exportTreatmentLogPdf } from "@/lib/export/api";
import { ReadinessItemCard, cardId } from "./readiness-item-card";
/** The first open item that is a duty; without one, the first open item. */
function nextItemToSolve(report: ReadinessReport): ReadinessItem | null {
const open = report.items.filter((item) => item.state === "OPEN");
return open.find((item) => item.severity === "PFLICHT") ?? open[0] ?? null;
}
function StatusBadge({ report }: { report: ReadinessReport }) {
if (report.status === "READY") {
return (
<Badge variant="default" className="h-6 gap-1.5 px-2.5 text-sm">
<CheckCircle2 aria-hidden />
Prüfbereit
</Badge>
);
}
return (
<Badge variant="destructive" className="h-6 gap-1.5 px-2.5 text-sm">
<XCircle aria-hidden />
{report.openCount} offen
</Badge>
);
}
function AreaSection({ area, items, canManage }: { area: (typeof AREA_ORDER)[number]; items: ReadinessItem[]; canManage: boolean }) {
const headingId = `area-${area}`;
return (
<section aria-labelledby={headingId} className="flex flex-col gap-2">
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
{AREA_LABEL[area]}
</h2>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Hier ist nichts offen.</p>
) : (
<ul className="flex flex-col gap-2">
{items.map((item) => (
<ReadinessItemCard key={`${item.key}-${item.subjectId}`} item={item} canManage={canManage} />
))}
</ul>
)}
</section>
);
}
export default function CompliancePage() {
const { hasPermission } = useAuth();
const query = useReadiness();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [actionError, setActionError] = useState<string | null>(null);
const year = new Date().getFullYear();
const treatmentLog = useMutation({
mutationFn: () => exportTreatmentLogPdf(year),
onMutate: () => setActionError(null),
onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
});
const report = useMutation({
mutationFn: downloadReadinessReport,
onMutate: () => setActionError(null),
onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
});
const data = query.data;
const grouped = useMemo(() => {
const items = data?.items ?? [];
return {
byArea: AREA_ORDER.map((area) => ({
area,
items: items.filter((item) => item.area === area && item.state !== "DONE"),
})),
done: items.filter((item) => item.state === "DONE"),
};
}, [data]);
const header = (
<DetailHeader
icon={ShieldCheck}
title="Prüfbereitschaft"
backHref="/dashboard"
backLabel="Start"
status={data ? <StatusBadge report={data} /> : undefined}
primaryAction={data ? primaryAction(data) : undefined}
moreActions={moreActions()}
/>
);
function primaryAction(current: ReadinessReport) {
const next = current.status === "OPEN" ? nextItemToSolve(current) : null;
if (next) {
const fix = fixLinkFor(next.key, next.subjects[0] ?? null);
// Without a page to fix it on, the button shows the item on this page.
return (
<Button size="touch" render={<Link href={fix.available ? fix.href : `#${cardId(next)}`} />}>
<TriangleAlert aria-hidden />
{fix.available ? "Nächsten Punkt lösen" : "Nächsten Punkt ansehen"}
</Button>
);
}
return (
<Button
size="touch"
disabled={isOffline || treatmentLog.isPending}
aria-describedby={describedBy}
onClick={() => treatmentLog.mutate()}
>
<Printer aria-hidden />
{`Bestandsbuch ${year} drucken`}
</Button>
);
}
function moreActions(): MoreAction[] {
const actions: MoreAction[] = [
{ label: "Bericht (PDF)", icon: FileText, onSelect: () => report.mutate(), disabled: isOffline || report.isPending },
];
if (data?.status === "OPEN") {
actions.push({
label: `Bestandsbuch ${year} drucken`,
icon: Printer,
onSelect: () => treatmentLog.mutate(),
disabled: isOffline || treatmentLog.isPending,
});
}
actions.push(
{ label: "Anzeigen ans Veterinäramt", icon: BookOpen, href: "/compliance/notices" },
{ label: "Sperrbezirke", icon: TriangleAlert, href: "/compliance/restrictions" },
{ label: "Einstellungen", icon: Settings, href: "/compliance/settings" },
);
return actions;
}
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
{header}
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError && data !== undefined} />
<OnlineOnlyHint id={hintId} />
<div aria-live="assertive">
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
</div>
<div aria-live="polite" className="flex flex-col gap-6">
{query.isLoading ? <LoadingState what="Die Prüfpunkte" /> : null}
{!query.isLoading && data === undefined ? (
<ErrorState what="Die Prüfpunkte" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
{data ? (
<>
{grouped.byArea.map(({ area, items }) => (
<AreaSection key={area} area={area} items={items} canManage={hasPermission("compliance:manage")} />
))}
{grouped.done.length > 0 ? (
<details className="rounded-xl border border-border/60 bg-card">
<summary className="flex min-h-11 cursor-pointer items-center px-3 font-heading text-base font-semibold">
Erledigt ({grouped.done.length})
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{grouped.done.map((item) => (
<ReadinessItemCard key={`${item.key}-${item.subjectId}`} item={item} canManage={false} />
))}
</ul>
</details>
) : null}
</>
) : null}
</div>
</main>
);
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReadinessItemCard } from "./readiness-item-card";
import { acknowledge } from "@/lib/compliance/api";
import { itemFixture } from "@/lib/compliance/fixtures";
import type { ReadinessItem } from "@/lib/compliance/types";
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/api", () => ({ acknowledge: vi.fn(), revokeAcknowledgement: vi.fn() }));
function renderCard(item: ReadinessItem, canManage = true) {
const client = new QueryClient();
return render(
<QueryClientProvider client={client}>
<ul>
<ReadinessItemCard item={item} canManage={canManage} />
</ul>
</QueryClientProvider>,
);
}
describe("ReadinessItemCard", () => {
it("shows the open item red with symbol, word, severity and the legal basis small", () => {
renderCard(itemFixture({ legalBasis: "Art. 108 VO (EU) 2019/6" }));
const card = screen.getByRole("listitem");
expect(card).toHaveClass("border-destructive/40");
expect(screen.getByText("Offen")).toBeInTheDocument();
expect(screen.getByText("Pflicht")).toBeInTheDocument();
expect(screen.getByText("Art. 108 VO (EU) 2019/6")).toHaveClass("text-xs");
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute(
"href",
"/settings/automation?tab=social-media",
);
});
it("shows only the count when the caller may not see the subjects", () => {
renderCard(itemFixture({ key: "restriction-active", subjects: [], more: 3 }));
expect(screen.getByText("3 betroffen")).toBeInTheDocument();
});
it("shows an acknowledged item calm, with who wrote which note", () => {
renderCard(
itemFixture({
state: "ACKNOWLEDGED",
acknowledgeable: true,
acknowledgement: {
id: "a1",
note: "Mit dem Amt abgesprochen.",
userName: "Nick",
createdAt: "2026-10-01T10:00:00.000Z",
},
}),
);
const card = screen.getByRole("listitem");
expect(card).not.toHaveClass("border-destructive/40");
expect(screen.getByText("Quittiert")).toBeInTheDocument();
expect(screen.getByText(/Mit dem Amt abgesprochen\./)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zurücknehmen" })).toBeInTheDocument();
});
it("hides the acknowledge actions without compliance:manage", () => {
renderCard(itemFixture({ acknowledgeable: true }), false);
expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
});
it("moves focus to the heading and announces Punkt quittiert after acknowledging", async () => {
vi.mocked(acknowledge).mockResolvedValue(undefined);
renderCard(itemFixture({ acknowledgeable: true, title: "Wichtiger Punkt" }));
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Punkt quittiert"));
expect(screen.getByRole("heading", { name: "Wichtiger Punkt" })).toHaveFocus();
});
it("hides Beheben while the target page does not exist", () => {
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { useRef, useState } from "react";
import { ArrowRight, CheckCircle2, ClipboardCheck, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { fixLinkFor } from "@/lib/compliance/fix-links";
import { SEVERITY_LABEL, itemTone } from "@/lib/compliance/labels";
import type { ReadinessItem, ReadinessItemState, ReadinessSubject } from "@/lib/compliance/types";
import { cn } from "@/lib/utils";
import { AcknowledgementActions } from "./acknowledge-dialog";
const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
OPEN: XCircle,
DONE: CheckCircle2,
ACKNOWLEDGED: ClipboardCheck,
};
const FIX_LINK_CLASS =
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
const { href, label, available } = fixLinkFor(itemKey, subject);
// Hidden while the target page does not exist (see ROUTE_AVAILABLE).
if (!available) return null;
return (
<Link href={href} className={FIX_LINK_CLASS}>
{label}
<span className="sr-only">: {context}</span>
<ArrowRight className="size-4" aria-hidden />
</Link>
);
}
function SubjectList({ item }: { item: ReadinessItem }) {
const total = item.subjects.length + item.more;
if (item.subjects.length === 0) {
// Without the right to see names only the count is left.
return item.more > 0 ? <p className="text-sm">{item.more} betroffen</p> : null;
}
return (
<details className="rounded-lg border border-border/60 bg-card">
<summary className="flex min-h-11 cursor-pointer items-center px-3 text-sm font-medium">
{total} betroffen
</summary>
<ul className="flex flex-col divide-y divide-border/60 px-3 pb-1">
{item.subjects.map((subject) => (
<li key={`${subject.type}-${subject.id}`} className="flex flex-wrap items-center justify-between gap-2 py-1 text-sm">
<span className="min-w-0 break-words">
{subject.label}
{subject.missing && subject.missing.length > 0 ? (
<span className="text-muted-foreground"> – es fehlt: {subject.missing.join(", ")}</span>
) : null}
</span>
<FixLink itemKey={item.key} subject={subject} context={subject.label} />
</li>
))}
</ul>
{item.more > 0 ? <p className="px-3 pb-2 text-sm text-muted-foreground">und {item.more} weitere</p> : null}
</details>
);
}
/** 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}`;
}
function formatDay(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE");
}
interface ReadinessItemCardProps {
item: ReadinessItem;
/** compliance:manage - acknowledging and revoking. */
canManage: boolean;
}
export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
const { tone, word } = itemTone(item.state);
const StateIcon = STATE_ICON[item.state];
const isOpen = item.state === "OPEN";
const { acknowledgement } = item;
const headingRef = useRef<HTMLHeadingElement>(null);
const [announcement, setAnnouncement] = useState("");
// After saving, the action buttons may be gone: focus moves to the heading.
function handleDone(message: string) {
setAnnouncement(message);
headingRef.current?.focus();
}
return (
<li
id={cardId(item)}
data-item-key={item.key}
className={cn(
"flex flex-col gap-2 rounded-xl border p-3",
isOpen ? "border-destructive/40 bg-destructive/5" : "border-border bg-card",
)}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={tone} className="h-6 gap-1.5 px-2.5 text-sm">
<StateIcon aria-hidden />
{word}
</Badge>
<Badge variant="outline" className="h-6 px-2.5 text-sm">
{SEVERITY_LABEL[item.severity]}
</Badge>
<h3
ref={headingRef}
tabIndex={-1}
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{item.title}
</h3>
</div>
<p className="text-sm">{item.detail}</p>
{item.legalBasis ? <p className="text-xs text-muted-foreground">{item.legalBasis}</p> : null}
{acknowledgement ? (
<p className="text-sm text-muted-foreground">
{acknowledgement.userName}, {formatDay(acknowledgement.createdAt)}: {acknowledgement.note}
</p>
) : null}
{isOpen ? <SubjectList item={item} /> : null}
<div className="flex flex-wrap items-center gap-2">
{isOpen && item.subjects.length === 0 ? (
<FixLink itemKey={item.key} subject={null} context={item.title} />
) : null}
{canManage ? <AcknowledgementActions item={item} onDone={handleDone} /> : null}
</div>
<p role="status" className="sr-only">
{announcement}
</p>
</li>
);
}
@@ -29,6 +29,9 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
fetchAutopilotToday: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/compliance/api", () => ({
fetchReadinessSummary: vi.fn().mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null }),
}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
@@ -26,6 +26,7 @@ import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null {
@@ -191,6 +192,8 @@ export default function DashboardPage() {
</div>
) : null}
<ComplianceLine />
<TodayPanel
tasks={tasksQuery.data}
isLoading={tasksQuery.isLoading || autopilotQuery.isLoading}
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LotFormDialog } from "./lot-form-dialog";
import { createLot, updateLot } from "@/lib/medicines/api";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/medicines/api", () => ({ createLot: vi.fn(), updateLot: vi.fn() }));
function renderDialog(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("LotFormDialog", () => {
beforeEach(() => {
vi.mocked(createLot).mockReset();
vi.mocked(updateLot).mockReset();
act(() => reportRequestOutcome("reached-server"));
});
it("books a lot with an Idempotency-Key and prefills the default supplier", async () => {
vi.mocked(createLot).mockResolvedValue(lot());
renderDialog(<LotFormDialog medicine={medicine({ defaultSupplier: "Tierarzt Muster" })} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
expect(await screen.findByLabelText(/Lieferant/)).toHaveValue("Tierarzt Muster");
await userEvent.type(screen.getByLabelText(/Menge/), "20");
await userEvent.type(screen.getByLabelText(/Chargennummer/), "CH-42");
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
expect(createLot).toHaveBeenCalledWith(
expect.objectContaining({
medicineId: "m1",
quantity: 20,
batchNumber: "CH-42",
supplier: "Tierarzt Muster",
purchasedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
}),
{ idempotencyKey: expect.any(String) },
);
});
it("labels the quantity with the unit of the medicine", async () => {
renderDialog(<LotFormDialog medicine={medicine({ unit: "ML" })} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
expect(await screen.findByLabelText(/^Menge \(ml\)/)).toBeInTheDocument();
});
it("needs a positive quantity", async () => {
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
await userEvent.click(await screen.findByRole("button", { name: "Erfassen" }));
expect(await screen.findByText("Bitte eine Menge über 0 eingeben.")).toBeInTheDocument();
expect(createLot).not.toHaveBeenCalled();
});
it("shows the server message for a unit mismatch or an inactive medicine", async () => {
vi.mocked(createLot).mockRejectedValue(new ApiError(400, "Das Mittel ist deaktiviert.", { message: "Das Mittel ist deaktiviert." }));
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
await userEvent.type(await screen.findByLabelText(/Menge/), "5");
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Das Mittel ist deaktiviert.");
});
it("edits a lot and clears an emptied optional field with null", async () => {
vi.mocked(updateLot).mockResolvedValue(lot());
renderDialog(<LotFormDialog medicine={medicine()} lot={lot()} />);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await userEvent.clear(await screen.findByLabelText(/Lieferant/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateLot).toHaveBeenCalledWith(
"l1",
expect.objectContaining({ supplier: null, quantity: 20, batchNumber: "CH-42" }),
);
expect(updateLot).toHaveBeenCalledWith("l1", expect.not.objectContaining({ medicineId: expect.anything() }));
});
it("is off offline, with the visible reason", async () => {
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
await screen.findByRole("button", { name: "Erfassen" });
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Erfassen" })).toBeDisabled();
});
it("shows the message of a 409 in the dialog", async () => {
vi.mocked(createLot).mockRejectedValue(new ApiError(409, "Die Packung wurde gerade geändert. Bitte neu laden.", {}));
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
await userEvent.type(await screen.findByLabelText(/Menge/), "5");
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert. Bitte neu laden.");
});
it("refuses an ambiguous thousands dot in the quantity", async () => {
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
await userEvent.type(await screen.findByLabelText(/Menge/), "1.000");
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
expect(
await screen.findByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5"),
).toBeInTheDocument();
expect(createLot).not.toHaveBeenCalled();
});
it("renews the Idempotency-Key after a 409 and when the values change after a network error", async () => {
vi.mocked(createLot)
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockRejectedValueOnce(new ApiError(409, "Gerade geändert.", {}))
.mockResolvedValue(lot());
renderDialog(<LotFormDialog medicine={medicine()} />);
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
const quantity = await screen.findByLabelText(/Menge/);
await userEvent.type(quantity, "5");
const submit = screen.getByRole("button", { name: "Erfassen" });
await userEvent.click(submit); // network error
await userEvent.click(submit); // unchanged retry: same key, network error again
await userEvent.type(quantity, "0"); // changed body
await userEvent.click(submit); // 409
await userEvent.click(submit); // unchanged retry after a server answer
const keys = vi.mocked(createLot).mock.calls.map((call) => call[1]?.idempotencyKey);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[1]);
expect(keys[3]).not.toBe(keys[2]);
});
});
@@ -0,0 +1,294 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createLot, updateLot } from "@/lib/medicines/api";
import { decimalMessage, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
import type { LotInput, LotView, MedicineView } from "@/lib/medicines/types";
import { todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
type LotMedicine = Pick<MedicineView, "id" | "name" | "unit"> &
Partial<Pick<MedicineView, "defaultSupplier">>;
interface LotFormDialogProps {
medicine: LotMedicine;
/** Edit this lot; without it the dialog records a new one. */
lot?: LotView;
/** Switched off from outside, e.g. offline or an inactive medicine; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
interface LotFormValues {
batchNumber: string;
purchasedOn: string;
quantity: string;
expiresOn: string;
supplier: string;
purchaseProof: string;
prescribingVet: string;
notes: string;
}
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
const INPUT_CLASS = "h-11 sm:h-11";
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
if (lot) {
return {
batchNumber: lot.batchNumber ?? "",
purchasedOn: lot.purchasedOn,
quantity: String(lot.quantity),
expiresOn: lot.expiresOn ?? "",
supplier: lot.supplier ?? "",
purchaseProof: lot.purchaseProof ?? "",
prescribingVet: lot.prescribingVet ?? "",
notes: lot.notes ?? "",
};
}
return {
batchNumber: "",
purchasedOn: todayLocalIsoDate(),
quantity: "",
expiresOn: "",
supplier: medicine.defaultSupplier ?? "",
purchaseProof: "",
prescribingVet: "",
notes: "",
};
}
function errorsOf(values: LotFormValues): LotFormErrors {
const errors: LotFormErrors = {};
if (values.purchasedOn === "") errors.purchasedOn = "Bitte das Kaufdatum angeben.";
const quantity = parseDecimal(values.quantity);
if (!Number.isFinite(quantity) || quantity <= 0) errors.quantity = decimalMessage(values.quantity, "Bitte eine Menge über 0 eingeben.");
return errors;
}
function toPatch(values: LotFormValues): Omit<LotInput, "medicineId"> {
return {
batchNumber: emptyToNull(values.batchNumber),
purchasedOn: values.purchasedOn,
quantity: parseDecimal(values.quantity),
expiresOn: emptyToNull(values.expiresOn),
supplier: emptyToNull(values.supplier),
purchaseProof: emptyToNull(values.purchaseProof),
prescribingVet: emptyToNull(values.prescribingVet),
notes: emptyToNull(values.notes),
};
}
/**
* Record or correct one bought pack. Batch number, supplier, proof and vet are
* entered here once and apply to every treatment taken from the pack.
*/
export function LotFormDialog({ medicine, lot, disabled = false, describedBy }: LotFormDialogProps) {
const [open, setOpen] = useState(false);
const isEdit = lot !== undefined;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
variant={isEdit ? "outline" : "default"}
size="touch"
disabled={disabled}
aria-describedby={describedBy}
>
{isEdit ? "Bearbeiten" : "Packung erfassen"}
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Packung bearbeiten" : "Packung erfassen"}</DialogTitle>
<DialogDescription>{medicine.name}</DialogDescription>
</DialogHeader>
<LotForm medicine={medicine} lot={lot} onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
function LotForm({ medicine, lot, onDone }: { medicine: LotMedicine; lot?: LotView; onDone: () => void }) {
const queryClient = useQueryClient();
const isEdit = lot !== undefined;
const unitLabel = MEDICINE_UNIT_LABELS[medicine.unit];
const [values, setValues] = useState(() => initialValues(medicine, lot));
const [localErrors, setLocalErrors] = useState<LotFormErrors>({});
const [saveError, setSaveError] = useState<SaveError | null>(null);
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const set = (key: keyof LotFormValues, value: string) =>
setValues((current) => ({ ...current, [key]: value }));
const errorOf = (key: keyof LotFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
const mutation = useMutation({
mutationFn: () => {
if (lot) return updateLot(lot.id, toPatch(values));
const input = { medicineId: medicine.id, ...toPatch(values) };
return createLot(input, { idempotencyKey: attempt.keyFor(input) });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
toast.success(isEdit ? "Packung gespeichert" : "Packung erfasst");
attempt.done();
onDone();
},
onError: (error) => {
attempt.failed(error);
setSaveError(
describeSaveError(error, isEdit ? "Die Packung wurde nicht gespeichert." : "Die Packung wurde nicht erfasst."),
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaveError(null);
const errors = errorsOf(values);
setLocalErrors(errors);
if (Object.keys(errors).length > 0 || isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Chargennummer" id="lot-batch" error={errorOf("batchNumber")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.batchNumber}
maxLength={200}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("batchNumber", event.target.value)}
/>
)}
</Field>
<Field label="Gekauft am" required id="lot-purchased" error={errorOf("purchasedOn")}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="date"
className={INPUT_CLASS}
value={values.purchasedOn}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("purchasedOn", event.target.value)}
/>
)}
</Field>
<Field label={`Menge (${unitLabel})`} required id="lot-quantity" error={errorOf("quantity")}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
inputMode="decimal"
className={INPUT_CLASS}
value={values.quantity}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("quantity", event.target.value)}
/>
)}
</Field>
<Field label="Haltbar bis" id="lot-expires" error={errorOf("expiresOn")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
type="date"
className={INPUT_CLASS}
value={values.expiresOn}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("expiresOn", event.target.value)}
/>
)}
</Field>
<Field label="Lieferant" id="lot-supplier" hint="Name und Anschrift" error={errorOf("supplier")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.supplier}
maxLength={300}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("supplier", event.target.value)}
/>
)}
</Field>
<Field label="Beleg" id="lot-proof" hint="z. B. Rechnung 4711" error={errorOf("purchaseProof")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.purchaseProof}
maxLength={300}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("purchaseProof", event.target.value)}
/>
)}
</Field>
<Field label="Verschreibender Tierarzt" id="lot-vet" error={errorOf("prescribingVet")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.prescribingVet}
maxLength={300}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
)}
</Field>
<FormSection title="Weitere Angaben" collapsible defaultOpen={values.notes !== ""} fieldCount={1}>
<Field label="Notizen" id="lot-notes" error={errorOf("notes")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Textarea
id={id}
value={values.notes}
maxLength={2000}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</FormSection>
{saveError?.general ? (
<p role="alert" className="text-sm text-destructive">
{saveError.general}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Erfassen"}
</Button>
</form>
);
}
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MedicineDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchMedicine } from "@/lib/medicines/api";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "m1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/medicines/api", () => ({
fetchMedicine: vi.fn(),
createLot: vi.fn(),
updateLot: vi.fn(),
updateMedicine: vi.fn(),
setMedicineActive: vi.fn(),
}));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MedicineDetailPage />
</QueryClientProvider>,
);
}
const ALL = ["veterinary-medicines:read", "veterinary-medicines:create", "veterinary-medicines:update"];
describe("MedicineDetailPage", () => {
beforeEach(() => {
vi.mocked(fetchMedicine).mockReset();
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot()] });
});
it("the main action in the header is Packung erfassen", async () => {
const { container } = renderPage(ALL);
await screen.findByRole("heading", { name: "Apivar" });
const header = container.querySelector("header") as HTMLElement;
expect(within(header).getByRole("button", { name: /Packung erfassen/ })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /Packung erfassen/ })).toHaveLength(1);
});
it("lists the lots with batch number, rest and expiry as links to the lot", async () => {
renderPage(ALL);
const link = await screen.findByRole("link", { name: /CH-42/ });
expect(link).toHaveAttribute("href", "/medicines/lots/l1");
expect(link).toHaveTextContent("12 Stück");
expect(link).toHaveTextContent("31.01.2028");
});
it("shows a negative lot rest in red with the word Negativ", async () => {
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot({ remaining: -3 })] });
renderPage(ALL);
const negative = await screen.findByText(/Negativ/);
expect(negative.closest("span.text-destructive")).not.toBeNull();
});
it("hides create buttons without veterinary-medicines:create", async () => {
renderPage(["veterinary-medicines:read", "veterinary-medicines:update"]);
await screen.findByRole("heading", { name: "Apivar" });
expect(screen.queryByRole("button", { name: /Packung erfassen/ })).not.toBeInTheDocument();
});
it("hides editing without veterinary-medicines:update", async () => {
renderPage(["veterinary-medicines:read"]);
await screen.findByRole("heading", { name: "Apivar" });
expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Deaktivieren/ })).not.toBeInTheDocument();
});
it("gives an inactive medicine no new lots and says why", async () => {
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ active: false }), lots: [] });
renderPage(ALL);
const button = await screen.findByRole("button", { name: /Packung erfassen/ });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Das Mittel ist deaktiviert.");
expect(screen.getByRole("button", { name: "Aktivieren" })).toBeInTheDocument();
});
it("shows the empty state of a medicine without lots", async () => {
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ lotCount: 0, totalRemaining: 0 }), lots: [] });
renderPage(ALL);
expect(await screen.findByText("Noch keine Packung")).toBeInTheDocument();
});
it("shows no data and no retry without veterinary-medicines:read", async () => {
renderPage([]);
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchMedicine).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,206 @@
"use client";
import { useId, type ReactNode } from "react";
import { useParams } from "next/navigation";
import { Package, Pill } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchMedicine, setMedicineActive } from "@/lib/medicines/api";
import { formatDay, formatQuantity, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
import type { LotView, MedicineView } from "@/lib/medicines/types";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { RestText } from "@/components/medicines/rest-text";
import { NoReadAccess } from "@/components/medicines/no-read-access";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MedicineFormDialog } from "../medicine-form-dialog";
import { LotFormDialog } from "./lot-form-dialog";
const BACK = { backHref: "/medicines", backLabel: "Alle Tierarzneimittel" };
const INACTIVE_REASON = "Das Mittel ist deaktiviert.";
export default function MedicineDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { isOffline, hintId } = useOnlineOnly();
const inactiveId = useId();
const query = useQuery({
queryKey: ["medicines", params.id],
queryFn: () => fetchMedicine(params.id),
enabled: hasPermission("veterinary-medicines:read"),
});
const medicine = query.data;
const activeMutation = useMutation({
mutationFn: (active: boolean) => setMedicineActive(params.id, active),
onSuccess: (_result, active) => {
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
toast.success(active ? "Mittel aktiviert" : "Mittel deaktiviert");
},
onError: (error) => toast.error(saveErrorText(error, "Das Mittel wurde nicht geändert.")),
});
if (!hasPermission("veterinary-medicines:read")) return <NoReadAccess />;
if (query.isLoading || query.isError || !medicine) {
return <DetailQueryFallback query={query} what="Daten des Mittels" {...BACK} />;
}
const canCreate = hasPermission("veterinary-medicines:create");
const canUpdate = hasPermission("veterinary-medicines:update");
const newLotReason = [isOffline ? hintId : null, medicine.active ? null : inactiveId]
.filter((id): id is string => id !== null)
.join(" ");
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Pill}
title={medicine.name}
subtitle={[medicine.activeIngredient, MEDICINE_UNIT_LABELS[medicine.unit]].filter(Boolean).join(" · ")}
status={
<DetailStatus
status={medicine.active ? "ACTIVE" : "INACTIVE"}
label={medicine.active ? "Aktiv" : "Deaktiviert"}
/>
}
primaryAction={
canCreate ? (
<LotFormDialog
medicine={medicine}
disabled={isOffline || !medicine.active}
describedBy={newLotReason || undefined}
/>
) : undefined
}
{...BACK}
/>
{!medicine.active ? (
<p id={inactiveId} className="text-sm text-muted-foreground">
{INACTIVE_REASON}
</p>
) : null}
{canUpdate || canCreate ? <OnlineOnlyHint id={hintId} /> : null}
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Angaben</CardTitle>
{canUpdate ? (
<div className="flex flex-wrap items-center gap-2">
<MedicineFormDialog medicine={medicine} disabled={isOffline} describedBy={isOffline ? hintId : undefined} />
{medicine.active ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerSize="touch"
triggerDisabled={isOffline || activeMutation.isPending}
triggerDescribedBy={isOffline ? hintId : undefined}
title="Mittel deaktivieren?"
description="Das Mittel nimmt keine neuen Packungen mehr an. Packungen, Bewegungsbuch und Behandlungen bleiben unverändert."
confirmLabel="Deaktivieren"
onConfirm={() => activeMutation.mutate(false)}
isPending={activeMutation.isPending}
/>
) : (
<Button
size="touch"
variant="outline"
disabled={isOffline || activeMutation.isPending}
aria-describedby={isOffline ? hintId : undefined}
onClick={() => activeMutation.mutate(true)}
>
Aktivieren
</Button>
)}
</div>
) : null}
</CardHeader>
<CardContent>
<MedicineFacts medicine={medicine} />
</CardContent>
</Card>
<section aria-labelledby="medicine-lots-heading" className="flex flex-col gap-3 lg:col-span-2">
<h2 id="medicine-lots-heading" className="text-lg font-semibold tracking-tight">
Packungen
</h2>
{medicine.lots.length === 0 ? (
<EmptyState
icon={Package}
title="Noch keine Packung"
description="Erfasse die gekaufte Packung mit Chargennummer und Beleg. Behandlungen buchen dann aus ihr ab."
/>
) : (
<LotGrid lots={medicine.lots} />
)}
</section>
</div>
</main>
);
}
function MedicineFacts({ medicine }: { medicine: MedicineView }) {
const facts: { label: string; value: ReactNode }[] = [
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
{ label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
{
label: "Wartezeit Honig",
value: medicine.withdrawalPeriodDays === null ? "—" : `${medicine.withdrawalPeriodDays} Tage`,
},
{ label: "Verschreibungspflichtig", value: medicine.prescriptionOnly ? "Ja" : "Nein" },
{ label: "Zulassungsnummer", value: medicine.authorisationNumber ?? "—" },
{
label: "Dosis je Volk",
value: medicine.dosePerColony === null ? "—" : formatQuantity(medicine.dosePerColony, medicine.unit),
},
{ label: "Dosis-Hinweis", value: medicine.doseNote ?? "—" },
{ label: "Anwendung", value: medicine.applicationMethod ?? "—" },
{ label: "Lagertemperatur", value: temperatureWindow(medicine) },
{ label: "Üblicher Lieferant", value: medicine.defaultSupplier ?? "—" },
{ label: "Notizen", value: medicine.notes ?? "—" },
];
return (
<dl className="flex flex-col gap-3 text-sm">
{facts.map((fact) => (
<div key={fact.label}>
<dt className="text-xs text-muted-foreground">{fact.label}</dt>
<dd className="font-medium break-words">{fact.value}</dd>
</div>
))}
</dl>
);
}
function temperatureWindow(medicine: MedicineView): string {
const { minTemperatureC: min, maxTemperatureC: max } = medicine;
if (min === null && max === null) return "—";
if (min !== null && max !== null) return `${min} bis ${max} °C`;
return min !== null ? `ab ${min} °C` : `bis ${max} °C`;
}
function LotGrid({ lots }: { lots: LotView[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{lots.map((lot) => (
<EntityCard
key={lot.id}
href={`/medicines/lots/${lot.id}`}
icon={Package}
title={lot.batchNumber ?? "Ohne Chargennummer"}
subtitle={`Gekauft am ${formatDay(lot.purchasedOn)}`}
fields={[
{ label: "Rest", value: <RestText value={lot.remaining} unit={lot.unit} /> },
{ label: "Haltbar bis", value: formatDay(lot.expiresOn) },
]}
/>
))}
</div>
);
}
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DisposalDialog } from "./disposal-dialog";
import { bookDisposal } from "@/lib/medicines/api";
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/medicines/api", () => ({ bookDisposal: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<DisposalDialog lotId="l1" remaining={12} unit="ML" />
</QueryClientProvider>,
);
}
async function fill(quantity: string, reason: string) {
await userEvent.click(screen.getByRole("button", { name: "Entsorgung" }));
if (quantity) await userEvent.type(await screen.findByLabelText(/^Menge/), quantity);
if (reason) await userEvent.type(await screen.findByLabelText(/^Grund/), reason);
}
describe("DisposalDialog", () => {
beforeEach(() => {
vi.mocked(bookDisposal).mockReset();
act(() => reportRequestOutcome("reached-server"));
});
it("Entsorgung needs a reason", async () => {
renderDialog();
await fill("2", "");
expect(screen.getByText("Bitte einen Grund angeben.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
expect(bookDisposal).not.toHaveBeenCalled();
});
it("a reason of blanks is no reason", async () => {
renderDialog();
await fill("2", " ");
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
});
it("needs a positive quantity", async () => {
renderDialog();
await fill("", "Abgelaufen");
expect(screen.getByText("Bitte eine Menge eingeben.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
});
it("books quantity and trimmed reason with an Idempotency-Key", async () => {
vi.mocked(bookDisposal).mockResolvedValue(lotDetail());
renderDialog();
await fill("2,5", " Abgelaufen ");
await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
expect(bookDisposal).toHaveBeenCalledWith("l1", 2.5, "Abgelaufen", {
idempotencyKey: expect.any(String),
});
});
it("lets more than the rest through (a negative rest is allowed) but says so", async () => {
renderDialog();
await fill("20", "Verschüttet");
expect(screen.getByText(/mehr als der Rest/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeEnabled();
});
it("shows the server message of a failed booking", async () => {
vi.mocked(bookDisposal).mockRejectedValue(new ApiError(400, "Die Menge ist zu klein", { message: "Die Menge ist zu klein" }));
renderDialog();
await fill("0,0001", "Test");
await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
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 () => {
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");
});
it("refuses an ambiguous thousands dot", async () => {
renderDialog();
await fill("1.000", "Abgelaufen");
expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
});
});
@@ -0,0 +1,165 @@
"use client";
import { FormEvent, useState } from "react";
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 type { MedicineUnit } from "@/lib/medicines/types";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface DisposalDialogProps {
lotId: string;
remaining: number;
unit: MedicineUnit;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/** „Entsorgung“: expired or spilled medicine leaves the lot, always with a reason. */
export function DisposalDialog({
lotId,
remaining,
unit,
disabled = false,
describedBy: triggerDescribedBy,
}: DisposalDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="touch" disabled={disabled} aria-describedby={triggerDescribedBy}>
<Trash2 aria-hidden />
Entsorgung
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Entsorgung</DialogTitle>
</DialogHeader>
<DisposalForm lotId={lotId} remaining={remaining} unit={unit} onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
interface DisposalFormProps {
lotId: string;
remaining: number;
unit: MedicineUnit;
onDone: () => void;
}
function problemWith(quantityText: string, reason: string): string | null {
const quantity = parseDecimal(quantityText);
if (quantityText.trim() === "" || !Number.isFinite(quantity)) {
return decimalMessage(quantityText, "Bitte eine Menge eingeben.");
}
if (quantity <= 0) return "Die Menge muss größer als 0 sein.";
if (reason.trim() === "") return "Bitte einen Grund angeben.";
return null;
}
function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
const queryClient = useQueryClient();
const [quantityText, setQuantityText] = useState("");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const problem = problemWith(quantityText, reason);
const quantity = parseDecimal(quantityText);
const exceedsRest = problem === null && quantity > remaining;
const note = problem ?? "";
const mutation = useMutation({
mutationFn: () => bookDisposal(lotId, quantity, reason.trim(), {
idempotencyKey: attempt.keyFor({ quantity, reason: reason.trim() }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
toast.success(`${formatQuantity(quantity, unit)} entsorgt`);
attempt.done();
onDone();
},
onError: (mutationError) => {
attempt.failed(mutationError);
setError(saveErrorText(mutationError, "Die Entsorgung wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (problem !== null || isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">Noch da: {formatQuantity(remaining, unit)}.</p>
<div className="flex flex-col gap-2">
<Label htmlFor="disposal-quantity">Menge ({MEDICINE_UNIT_LABELS[unit]})</Label>
<Input
id="disposal-quantity"
inputMode="decimal"
className="h-11 sm:h-11"
value={quantityText}
onChange={(event) => setQuantityText(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="disposal-reason">Grund</Label>
<Input
id="disposal-reason"
className="h-11 sm:h-11"
value={reason}
maxLength={500}
placeholder="z. B. abgelaufen, verschüttet"
onChange={(event) => setReason(event.target.value)}
/>
</div>
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{note}
{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.
</span>
) : null}
</p>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || problem !== null || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Bucht…" : "Entsorgung buchen"}
</Button>
</form>
);
}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InventoryDialog } from "./inventory-dialog";
import { bookCorrection } from "@/lib/medicines/api";
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/medicines/api", () => ({ bookCorrection: vi.fn() }));
function renderDialog(remaining = 12) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<InventoryDialog lotId="l1" remaining={remaining} unit="PIECE" />
</QueryClientProvider>,
);
}
async function open() {
await userEvent.click(screen.getByRole("button", { name: "Inventur" }));
}
describe("InventoryDialog", () => {
beforeEach(() => {
vi.mocked(bookCorrection).mockReset();
act(() => reportRequestOutcome("reached-server"));
});
it("asks for the actual rest and shows the delta before saving", async () => {
renderDialog();
await open();
const input = await screen.findByLabelText("Tatsächlicher Rest (Stück)");
expect(input).toHaveValue("12");
expect(screen.getByText("Keine Abweichung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
await userEvent.clear(input);
await userEvent.type(input, "9");
expect(screen.getByText("Differenz: −3 Stück")).toBeInTheDocument();
await userEvent.clear(input);
await userEvent.type(input, "13,5");
expect(screen.getByText("Differenz: +1,5 Stück")).toBeInTheDocument();
});
it("books the counted rest with an Idempotency-Key", async () => {
vi.mocked(bookCorrection).mockResolvedValue(lotDetail());
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "9");
await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
expect(bookCorrection).toHaveBeenCalledWith("l1", 9, { idempotencyKey: expect.any(String) });
});
it("does not accept a negative count", async () => {
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "-1");
expect(screen.getByText("Der Rest kann nicht negativ gezählt werden.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
});
it("shows a failed booking in the dialog", async () => {
vi.mocked(bookCorrection).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "9");
await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
});
it("is off offline, with the visible reason", async () => {
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "9");
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
});
it("points the submit button at the status line", async () => {
renderDialog();
await open();
expect(await screen.findByRole("button", { name: "Inventur buchen" })).toHaveAttribute(
"aria-describedby",
expect.stringContaining("inventory-status"),
);
});
it("refuses an ambiguous thousands dot", async () => {
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "1.000");
expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
});
it("keeps the key for an unchanged retry after a network error, renews it after a 409 or a change", async () => {
vi.mocked(bookCorrection)
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockRejectedValueOnce(new ApiError(409, "Die Packung wurde gerade geändert.", {}))
.mockResolvedValue(lotDetail());
renderDialog();
await open();
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
await userEvent.clear(input);
await userEvent.type(input, "9");
const submit = screen.getByRole("button", { name: "Inventur buchen" });
await userEvent.click(submit); // network error
await userEvent.click(submit); // same body: same key
await userEvent.clear(input);
await userEvent.type(input, "8"); // changed body
await userEvent.click(submit); // 409
expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert.");
await userEvent.click(submit); // same body after a server answer
const keys = vi.mocked(bookCorrection).mock.calls.map((call) => call[2]?.idempotencyKey);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[1]);
expect(keys[3]).not.toBe(keys[2]);
});
});
@@ -0,0 +1,156 @@
"use client";
import { FormEvent, useState } from "react";
import { ClipboardList } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { bookCorrection } from "@/lib/medicines/api";
import {
decimalMessage,
formatQuantity,
formatSignedQuantity,
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";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface InventoryDialogProps {
lotId: string;
/** The booked rest. */
remaining: number;
unit: MedicineUnit;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/** „Inventur“: the beekeeper types what is left in the box; the server books the difference. */
export function InventoryDialog({
lotId,
remaining,
unit,
disabled = false,
describedBy: triggerDescribedBy,
}: InventoryDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="touch" disabled={disabled} aria-describedby={triggerDescribedBy}>
<ClipboardList aria-hidden />
Inventur
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Inventur</DialogTitle>
</DialogHeader>
<InventoryForm lotId={lotId} remaining={remaining} unit={unit} onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
interface InventoryFormProps {
lotId: string;
remaining: number;
unit: MedicineUnit;
onDone: () => void;
}
function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
const queryClient = useQueryClient();
// Fixed when the dialog opens, so a reload behind it cannot move the booking.
const [baseline] = useState(remaining);
const [counted, setCounted] = useState(baseline < 0 ? "" : String(baseline).replace(".", ","));
const [error, setError] = useState<string | null>(null);
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const value = parseDecimal(counted);
const isNumber = Number.isFinite(value);
const isNegative = isNumber && value < 0;
const delta = isNumber && !isNegative ? roundToThousandths(value - baseline) : 0;
const status = !isNumber
? decimalMessage(counted, "Bitte den gezählten Rest eingeben.")
: isNegative
? "Der Rest kann nicht negativ gezählt werden."
: delta === 0
? "Keine Abweichung"
: `Differenz: ${formatSignedQuantity(delta, unit)}`;
const mutation = useMutation({
mutationFn: () => bookCorrection(lotId, value, { idempotencyKey: attempt.keyFor({ actualRemaining: value }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
toast.success(`Inventur gebucht: ${formatSignedQuantity(delta, unit)}`);
attempt.done();
onDone();
},
onError: (mutationError) => {
attempt.failed(mutationError);
setError(saveErrorText(mutationError, "Die Inventur wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!isNumber || isNegative || delta === 0 || isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">
Gebucht sind {formatQuantity(baseline, unit)}. Zähl, was in der Packung ist – gebucht wird die
Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="inventory-counted">Tatsächlicher Rest ({MEDICINE_UNIT_LABELS[unit]})</Label>
<Input
id="inventory-counted"
inputMode="decimal"
className="h-11 sm:h-11"
value={counted}
aria-describedby="inventory-status"
onChange={(event) => setCounted(event.target.value)}
/>
<p id="inventory-status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{status}
</p>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || !isNumber || isNegative || delta === 0 || isOffline}
aria-describedby={[describedBy, "inventory-status"].filter(Boolean).join(" ")}
>
{mutation.isPending ? "Bucht…" : "Inventur buchen"}
</Button>
</form>
);
}
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import LotDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchLot } from "@/lib/medicines/api";
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "l1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/medicines/api", () => ({
fetchLot: vi.fn(),
updateLot: vi.fn(),
bookCorrection: vi.fn(),
bookDisposal: vi.fn(),
}));
vi.mock("@/components/document-upload-card", () => ({
DocumentUploadCard: ({
entityType,
entityId,
canWrite,
}: {
entityType: string;
entityId: string;
canWrite?: boolean;
}) => (
<p>
Upload {entityType} {entityId} {canWrite ? "schreiben" : "nur lesen"}
</p>
),
}));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<LotDetailPage />
</QueryClientProvider>,
);
}
const ALL = ["veterinary-medicines:read", "veterinary-medicines:update"];
describe("LotDetailPage", () => {
beforeEach(() => {
vi.mocked(fetchLot).mockReset();
vi.mocked(fetchLot).mockResolvedValue(lotDetail());
});
it("shows the rest, the movement book and the receipt upload card", async () => {
renderPage(ALL);
expect(await screen.findByRole("heading", { name: /CH-42/ })).toBeInTheDocument();
expect(screen.getByText("Rest")).toBeInTheDocument();
expect(screen.getAllByText("12 Stück").length).toBeGreaterThan(0);
const book = screen.getByRole("table", { name: "Bewegungsbuch" });
expect(within(book).getByText("Behandlung")).toBeInTheDocument();
expect(within(book).getByText("−8 Stück")).toBeInTheDocument();
expect(within(book).getByText("Eingang")).toBeInTheDocument();
expect(screen.getByText("Upload MEDICINE_LOT l1 schreiben")).toBeInTheDocument();
});
it("shows the running rest after every booking", async () => {
renderPage(ALL);
const book = await screen.findByRole("table", { name: "Bewegungsbuch" });
const rows = within(book).getAllByRole("row");
expect(within(rows[1]).getByText("12 Stück")).toBeInTheDocument();
expect(within(rows[2]).getByText("20 Stück")).toBeInTheDocument();
});
it("a negative rest is shown in red with the word Negativ", async () => {
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: -3 }));
renderPage(ALL);
const negative = await screen.findByText(/Negativ/);
expect(negative.closest("span.text-destructive")).not.toBeNull();
});
it("the main action in the header is Inventur", async () => {
const { container } = renderPage(ALL);
await screen.findByRole("heading", { name: /CH-42/ });
const header = container.querySelector("header") as HTMLElement;
expect(within(header).getByRole("button", { name: /Inventur/ })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /Inventur/ })).toHaveLength(1);
expect(screen.getByRole("button", { name: /Entsorgung/ })).toBeInTheDocument();
});
it("hides inventory, disposal and editing without veterinary-medicines:update", async () => {
renderPage(["veterinary-medicines:read"]);
await screen.findByRole("heading", { name: /CH-42/ });
expect(screen.queryByRole("button", { name: /Inventur/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Entsorgung/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
});
it("names the empty book instead of showing a bare table", async () => {
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ movements: [], remaining: 20 }));
renderPage(ALL);
expect(await screen.findByText("Noch keine Bewegung außer dem Eingang.")).toBeInTheDocument();
});
it("lets a reader see and download the receipts but not change them", async () => {
renderPage(["veterinary-medicines:read"]);
expect(await screen.findByText("Upload MEDICINE_LOT l1 nur lesen")).toBeInTheDocument();
});
it("shows no data and no retry without veterinary-medicines:read", async () => {
renderPage([]);
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchLot).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
expect(screen.queryByText("Upload MEDICINE_LOT l1 nur lesen")).not.toBeInTheDocument();
});
it("warns when the running rest does not end at the rest the server reports", async () => {
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: 99 }));
renderPage(ALL);
expect(await screen.findByText("Stand weicht vom Server ab – bitte neu laden")).toBeInTheDocument();
});
it("stays quiet when book and server agree", async () => {
renderPage(ALL);
await screen.findByRole("table", { name: "Bewegungsbuch" });
expect(screen.queryByText(/weicht vom Server ab/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,187 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { buildBook } from "@/lib/medicines/book";
import { fetchLot } from "@/lib/medicines/api";
import { formatDay, formatQuantity, formatSignedQuantity } from "@/lib/medicines/format";
import type { LotDetailView } from "@/lib/medicines/types";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { RestText } from "@/components/medicines/rest-text";
import { NoReadAccess } from "@/components/medicines/no-read-access";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { LotFormDialog } from "../../[id]/lot-form-dialog";
import { DisposalDialog } from "./disposal-dialog";
import { InventoryDialog } from "./inventory-dialog";
export default function LotDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const query = useQuery({
queryKey: ["medicine-lots", params.id],
queryFn: () => fetchLot(params.id),
enabled: hasPermission("veterinary-medicines:read"),
});
const lot = query.data;
if (!hasPermission("veterinary-medicines:read")) return <NoReadAccess />;
if (query.isLoading || query.isError || !lot) {
return (
<DetailQueryFallback
query={query}
what="Daten der Packung"
backHref="/medicines"
backLabel="Alle Tierarzneimittel"
/>
);
}
const canUpdate = hasPermission("veterinary-medicines:update");
const offlineReason = isOffline ? describedBy : undefined;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Package}
title={`Packung ${lot.batchNumber ?? formatDay(lot.purchasedOn)}`}
subtitle={
<>
<Link href={`/medicines/${lot.medicineId}`} className="underline underline-offset-2">
{lot.medicineName}
</Link>
{` · gekauft am ${formatDay(lot.purchasedOn)}`}
</>
}
primaryAction={
canUpdate ? (
<InventoryDialog
lotId={lot.id}
remaining={lot.remaining}
unit={lot.unit}
disabled={isOffline}
describedBy={offlineReason}
/>
) : undefined
}
backHref={`/medicines/${lot.medicineId}`}
backLabel={`Zurück zu ${lot.medicineName}`}
/>
{canUpdate ? <OnlineOnlyHint id={hintId} /> : null}
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Angaben</CardTitle>
{canUpdate ? (
<div className="flex flex-wrap items-center gap-2">
<LotFormDialog
medicine={{ id: lot.medicineId, name: lot.medicineName, unit: lot.unit }}
lot={lot}
disabled={isOffline}
describedBy={offlineReason}
/>
<DisposalDialog
lotId={lot.id}
remaining={lot.remaining}
unit={lot.unit}
disabled={isOffline}
describedBy={offlineReason}
/>
</div>
) : null}
</CardHeader>
<CardContent>
<LotFacts lot={lot} />
</CardContent>
</Card>
<DocumentUploadCard entityType="MEDICINE_LOT" entityId={lot.id} title="Beleg" canWrite={canUpdate} />
</div>
<MovementBook lot={lot} />
</div>
</main>
);
}
function LotFacts({ lot }: { lot: LotDetailView }) {
const facts: { label: string; value: ReactNode }[] = [
{ label: "Rest", value: <RestText value={lot.remaining} unit={lot.unit} /> },
{ label: "Anfangsmenge", value: formatQuantity(lot.quantity, lot.unit) },
{ label: "Haltbar bis", value: formatDay(lot.expiresOn) },
{ label: "Chargennummer", value: lot.batchNumber ?? "—" },
{ label: "Lieferant", value: lot.supplier ?? "—" },
{ label: "Beleg", value: lot.purchaseProof ?? "—" },
{ label: "Verschreibender Tierarzt", value: lot.prescribingVet ?? "—" },
{ label: "Notizen", value: lot.notes ?? "—" },
];
return (
<dl className="flex flex-col gap-3 text-sm">
{facts.map((fact) => (
<div key={fact.label}>
<dt className="text-xs text-muted-foreground">{fact.label}</dt>
<dd className="font-medium break-words">{fact.value}</dd>
</div>
))}
</dl>
);
}
function MovementBook({ lot }: { lot: LotDetailView }) {
const rows = buildBook(lot);
return (
<section aria-labelledby="movement-book-heading" className="flex flex-col gap-3 lg:col-span-2">
<h2 id="movement-book-heading" className="text-lg font-semibold tracking-tight">
Bewegungen
</h2>
<div className="overflow-x-auto rounded-xl border">
<Table aria-label="Bewegungsbuch">
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Menge</TableHead>
<TableHead className="text-right">Stand danach</TableHead>
<TableHead>Grund</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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">
<RestText value={row.balance} unit={lot.unit} />
</TableCell>
<TableCell className="whitespace-normal break-words">{row.reason ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{rows[0].balance !== lot.remaining ? (
<p className="text-sm text-destructive">Stand weicht vom Server ab – bitte neu laden</p>
) : null}
{lot.movements.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Bewegung außer dem Eingang.</p>
) : null}
</section>
);
}
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MedicineFormDialog } from "./medicine-form-dialog";
import { createMedicine, updateMedicine } from "@/lib/medicines/api";
import { medicine } from "@/lib/medicines/fixtures.spec-helper";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/medicines/api", () => ({ createMedicine: vi.fn(), updateMedicine: vi.fn() }));
function renderDialog(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("MedicineFormDialog", () => {
beforeEach(() => {
vi.mocked(createMedicine).mockReset();
vi.mocked(updateMedicine).mockReset();
act(() => reportRequestOutcome("reached-server"));
});
it("creates a medicine with an Idempotency-Key and the typed values", async () => {
vi.mocked(createMedicine).mockResolvedValue(medicine());
renderDialog(<MedicineFormDialog />);
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
await userEvent.type(await screen.findByLabelText(/^Name/), "Apivar");
await userEvent.click(screen.getByRole("radio", { name: "Stück" }));
await userEvent.type(screen.getByLabelText(/Wartezeit/), "0");
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createMedicine).toHaveBeenCalledWith(
expect.objectContaining({
name: "Apivar",
unit: "PIECE",
withdrawalPeriodDays: 0,
prescriptionOnly: false,
}),
{ idempotencyKey: expect.any(String) },
);
});
it("asks for a name and a unit before it sends anything", async () => {
renderDialog(<MedicineFormDialog />);
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
expect(screen.getByText("Bitte eine Einheit wählen.")).toBeInTheDocument();
expect(createMedicine).not.toHaveBeenCalled();
});
it("keeps the rare fields folded away until they are asked for", async () => {
renderDialog(<MedicineFormDialog />);
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
expect(screen.queryByLabelText(/Niedrigste Lagertemperatur/)).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: /Weitere Angaben/ }));
expect(screen.getByLabelText(/Niedrigste Lagertemperatur/)).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis-Hinweis/)).toBeInTheDocument();
expect(screen.getByLabelText(/Notizen/)).toBeInTheDocument();
});
it("hides the unit field once lots exist and shows the server message", async () => {
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 2 })} />);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
expect(
await screen.findByText("Einheit ist festgelegt, sobald Packungen erfasst sind"),
).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Stück" })).not.toBeInTheDocument();
});
it("offers the unit while no lot exists, and never sends it once lots exist", async () => {
vi.mocked(updateMedicine).mockResolvedValue(medicine());
const { unmount } = renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 0 })} />);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
expect(await screen.findByRole("radio", { name: "Stück" })).toBeChecked();
unmount();
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 2 })} />);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await userEvent.click(await screen.findByRole("button", { name: "Speichern" }));
expect(updateMedicine).toHaveBeenCalledWith(
"m1",
expect.not.objectContaining({ unit: expect.anything() }),
);
});
it("clears an emptied optional field with null", async () => {
vi.mocked(updateMedicine).mockResolvedValue(medicine());
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 1 })} />);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await userEvent.clear(await screen.findByLabelText(/Wirkstoff/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateMedicine).toHaveBeenCalledWith(
"m1",
expect.objectContaining({ activeIngredient: null }),
);
});
it("shows the German message of a failed save in the dialog", async () => {
vi.mocked(createMedicine).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
renderDialog(
<MedicineFormDialog prefill={{ name: "Apivar", unit: "PIECE" }} triggerLabel="Übernehmen" />,
);
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
});
it("is off offline, with the visible reason", async () => {
renderDialog(<MedicineFormDialog />);
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
await screen.findByRole("button", { name: "Anlegen" });
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Anlegen" })).toBeDisabled();
});
});
@@ -0,0 +1,342 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createMedicine, updateMedicine } from "@/lib/medicines/api";
import { MEDICINE_UNITS, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
import type { MedicineUnit, MedicineView } from "@/lib/medicines/types";
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
initialMedicineValues,
medicineFormErrors,
toMedicineInput,
type MedicineFormValues,
} from "./medicine-form-values";
export const UNIT_LOCKED_TEXT = "Einheit ist festgelegt, sobald Packungen erfasst sind";
interface MedicineFormDialogProps {
/** Edit this medicine; without it the dialog creates one. */
medicine?: MedicineView;
/** Values proposed from a treatment (create only). */
prefill?: MedicineFormPrefill;
triggerLabel?: string;
triggerAriaLabel?: string;
triggerVariant?: "default" | "outline";
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/** Create or edit one catalogue entry; every value is typed in, the code ships none. */
export function MedicineFormDialog({
medicine,
prefill,
triggerLabel,
triggerAriaLabel,
triggerVariant,
disabled = false,
describedBy,
}: MedicineFormDialogProps) {
const [open, setOpen] = useState(false);
const isEdit = medicine !== undefined;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
variant={triggerVariant ?? (isEdit ? "outline" : "default")}
size="touch"
disabled={disabled}
aria-describedby={describedBy}
aria-label={triggerAriaLabel}
>
{triggerLabel ?? (isEdit ? "Bearbeiten" : "Mittel anlegen")}
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Mittel bearbeiten" : "Neues Tierarzneimittel"}</DialogTitle>
</DialogHeader>
<MedicineForm medicine={medicine} prefill={prefill} onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
interface MedicineFormProps {
medicine?: MedicineView;
prefill?: MedicineFormPrefill;
onDone: () => void;
}
const INPUT_CLASS = "h-11 sm:h-11";
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
const queryClient = useQueryClient();
const isEdit = medicine !== undefined;
const unitLocked = isEdit && medicine.lotCount > 0;
const [values, setValues] = useState<MedicineFormValues>(() => initialMedicineValues(medicine, prefill));
const [localErrors, setLocalErrors] = useState<Partial<Record<keyof MedicineFormValues, string>>>({});
const [saveError, setSaveError] = useState<SaveError | null>(null);
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const set = <K extends keyof MedicineFormValues>(key: K, value: MedicineFormValues[K]) =>
setValues((current) => ({ ...current, [key]: value }));
const errorOf = (key: keyof MedicineFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
const mutation = useMutation({
mutationFn: () => {
const input = toMedicineInput(values, unitLocked);
return medicine
? updateMedicine(medicine.id, input)
: createMedicine(input as Parameters<typeof createMedicine>[0], { idempotencyKey: attempt.keyFor(input) });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
void queryClient.invalidateQueries({ queryKey: ["medicine-suggestions"] });
toast.success(isEdit ? "Mittel gespeichert" : "Mittel angelegt");
attempt.done();
onDone();
},
onError: (error) => {
attempt.failed(error);
setSaveError(
describeSaveError(error, isEdit ? "Das Mittel wurde nicht gespeichert." : "Das Mittel wurde nicht angelegt."),
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaveError(null);
const errors = medicineFormErrors(values, unitLocked);
setLocalErrors(errors);
if (Object.keys(errors).length > 0 || isOffline) return;
mutation.mutate();
}
const hasRareValues = [
values.authorisationNumber,
values.dosePerColony,
values.doseNote,
values.minTemperatureC,
values.maxTemperatureC,
values.notes,
].some((value) => value.trim() !== "");
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
{prefill && !isEdit ? (
<p className="rounded-lg bg-muted px-3 py-2 text-sm">
Vorbelegt aus deiner letzten Behandlung. Prüfe die Werte gegen die Packungsbeilage, bevor du
speicherst.
</p>
) : null}
<Field label="Name (Handelsname)" required error={errorOf("name")} id="medicine-name">
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.name}
maxLength={200}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("name", event.target.value)}
/>
)}
</Field>
{unitLocked ? (
<div className="flex flex-col gap-0.5 text-sm">
<p className="font-medium">Einheit: {MEDICINE_UNIT_LABELS[medicine.unit]}</p>
<p className="text-muted-foreground">{UNIT_LOCKED_TEXT}</p>
</div>
) : (
<Field label="Einheit" required group error={errorOf("unit")} id="medicine-unit">
{({ id, labelId, describedBy: fieldDescribedBy }) => (
<ChoiceGroup<MedicineUnit | "">
id={id}
labelledBy={labelId}
describedBy={fieldDescribedBy}
options={choiceOptions(MEDICINE_UNIT_LABELS, MEDICINE_UNITS)}
value={values.unit}
onChange={(unit) => set("unit", unit)}
className="grid-cols-3"
/>
)}
</Field>
)}
<Field label="Wirkstoff" id="medicine-ingredient" error={errorOf("activeIngredient")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.activeIngredient}
maxLength={200}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("activeIngredient", event.target.value)}
/>
)}
</Field>
<Field
label="Wartezeit Honig (Tage)"
id="medicine-withdrawal"
hint="0 ist ein Wert: keine Wartezeit. Leer lassen, wenn du sie nicht kennst."
error={errorOf("withdrawalPeriodDays")}
>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
step={1}
className={INPUT_CLASS}
value={values.withdrawalPeriodDays}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("withdrawalPeriodDays", event.target.value)}
/>
)}
</Field>
<ToggleChip
label="Verschreibungspflichtig"
checked={values.prescriptionOnly}
onChange={(checked) => set("prescriptionOnly", checked)}
/>
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.applicationMethod}
maxLength={200}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("applicationMethod", event.target.value)}
/>
)}
</Field>
<Field label="Üblicher Lieferant" id="medicine-supplier" error={errorOf("defaultSupplier")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.defaultSupplier}
maxLength={300}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("defaultSupplier", event.target.value)}
/>
)}
</Field>
<FormSection title="Weitere Angaben" collapsible defaultOpen={hasRareValues} fieldCount={6}>
<Field label="Zulassungsnummer" id="medicine-authorisation" error={errorOf("authorisationNumber")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
className={INPUT_CLASS}
value={values.authorisationNumber}
maxLength={200}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("authorisationNumber", event.target.value)}
/>
)}
</Field>
<Field label="Dosis je Volk" id="medicine-dose" error={errorOf("dosePerColony")}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
inputMode="decimal"
className={INPUT_CLASS}
value={values.dosePerColony}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("dosePerColony", event.target.value)}
/>
)}
</Field>
<Field label="Dosis-Hinweis" id="medicine-dose-note" error={errorOf("doseNote")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Textarea
id={id}
value={values.doseNote}
maxLength={500}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("doseNote", event.target.value)}
/>
)}
</Field>
<Field label="Niedrigste Lagertemperatur (°C)" id="medicine-min-temp" error={errorOf("minTemperatureC")}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
inputMode="decimal"
className={INPUT_CLASS}
value={values.minTemperatureC}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("minTemperatureC", event.target.value)}
/>
)}
</Field>
<Field label="Höchste Lagertemperatur (°C)" id="medicine-max-temp" error={errorOf("maxTemperatureC")}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
inputMode="decimal"
className={INPUT_CLASS}
value={values.maxTemperatureC}
aria-invalid={invalid}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("maxTemperatureC", event.target.value)}
/>
)}
</Field>
<Field label="Notizen" id="medicine-notes" error={errorOf("notes")}>
{({ id, describedBy: fieldDescribedBy }) => (
<Textarea
id={id}
value={values.notes}
maxLength={2000}
aria-describedby={fieldDescribedBy}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</FormSection>
{saveError?.general ? (
<p role="alert" className="text-sm text-destructive">
{saveError.general}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Anlegen"}
</Button>
</form>
);
}
@@ -0,0 +1,100 @@
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
import type { MedicineInput, MedicineUnit, MedicineView } from "@/lib/medicines/types";
export interface MedicineFormValues {
name: string;
unit: MedicineUnit | "";
activeIngredient: string;
authorisationNumber: string;
prescriptionOnly: boolean;
withdrawalPeriodDays: string;
dosePerColony: string;
doseNote: string;
applicationMethod: string;
minTemperatureC: string;
maxTemperatureC: string;
defaultSupplier: string;
notes: string;
}
const text = (value: string | null | undefined): string => value ?? "";
const num = (value: number | null | undefined): string => (value === null || value === undefined ? "" : String(value));
export function initialMedicineValues(
medicine?: MedicineView,
prefill?: MedicineFormPrefill,
): MedicineFormValues {
const source: MedicineFormPrefill = medicine ?? prefill ?? {};
const full: Partial<MedicineView> = medicine ?? {};
return {
name: text(source.name),
unit: source.unit ?? "",
activeIngredient: text(source.activeIngredient),
authorisationNumber: text(full.authorisationNumber),
prescriptionOnly: full.prescriptionOnly ?? false,
withdrawalPeriodDays: num(source.withdrawalPeriodDays),
dosePerColony: num(full.dosePerColony),
doseNote: text(source.doseNote),
applicationMethod: text(source.applicationMethod),
minTemperatureC: num(full.minTemperatureC),
maxTemperatureC: num(full.maxTemperatureC),
defaultSupplier: text(full.defaultSupplier),
notes: text(full.notes),
};
}
export type MedicineFormErrors = Partial<Record<keyof MedicineFormValues, string>>;
const NOT_A_NUMBER = "Bitte eine Zahl eingeben.";
function decimalProblem(value: string): string | null {
return value.trim() !== "" && !Number.isFinite(parseDecimal(value)) ? decimalMessage(value, NOT_A_NUMBER) : null;
}
/** What keeps the form from being sent, per field; empty = fine. */
export function medicineFormErrors(values: MedicineFormValues, unitLocked: boolean): MedicineFormErrors {
const errors: MedicineFormErrors = {};
if (values.name.trim() === "") errors.name = "Bitte einen Namen eingeben.";
if (!unitLocked && values.unit === "") errors.unit = "Bitte eine Einheit wählen.";
const days = values.withdrawalPeriodDays.trim();
if (days !== "" && !(Number.isInteger(Number(days)) && Number(days) >= 0)) {
errors.withdrawalPeriodDays = "Bitte ganze Tage ab 0 eingeben.";
}
const dose = decimalProblem(values.dosePerColony);
if (dose) errors.dosePerColony = dose;
else if (values.dosePerColony.trim() !== "" && parseDecimal(values.dosePerColony) <= 0) {
errors.dosePerColony = "Die Dosis muss größer als 0 sein.";
}
const min = decimalProblem(values.minTemperatureC);
if (min) errors.minTemperatureC = min;
const max = decimalProblem(values.maxTemperatureC);
if (max) errors.maxTemperatureC = max;
if (!min && !max && values.minTemperatureC.trim() !== "" && values.maxTemperatureC.trim() !== "") {
if (parseDecimal(values.minTemperatureC) > parseDecimal(values.maxTemperatureC)) {
errors.minTemperatureC = "Die niedrigste Temperatur liegt über der höchsten.";
}
}
return errors;
}
/** An emptied optional field goes out as null, so it can be cleared (owner rule). */
export function toMedicineInput(values: MedicineFormValues, unitLocked: boolean): Partial<MedicineInput> {
const base = {
name: values.name.trim(),
activeIngredient: emptyToNull(values.activeIngredient),
authorisationNumber: emptyToNull(values.authorisationNumber),
prescriptionOnly: values.prescriptionOnly,
withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number),
dosePerColony: emptyToNull(values.dosePerColony, parseDecimal),
doseNote: emptyToNull(values.doseNote),
applicationMethod: emptyToNull(values.applicationMethod),
minTemperatureC: emptyToNull(values.minTemperatureC, parseDecimal),
maxTemperatureC: emptyToNull(values.maxTemperatureC, parseDecimal),
defaultSupplier: emptyToNull(values.defaultSupplier),
notes: emptyToNull(values.notes),
};
return unitLocked || values.unit === "" ? base : { ...base, unit: values.unit };
}
@@ -0,0 +1,80 @@
"use client";
import { Pill } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchMedicineSuggestions } from "@/lib/medicines/api";
import { prefillFromSuggestion } from "@/lib/medicines/prefill";
import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MedicineFormDialog } from "./medicine-form-dialog";
interface MedicineSuggestionsProps {
canCreate: boolean;
disabled?: boolean;
describedBy?: string;
}
/**
* The catalogue is empty: offer the product names from the beekeeper's own
* treatments instead of a blank page. Nothing is created by looking at them;
* „Übernehmen“ opens the form with the values of the latest treatment, which
* the beekeeper checks before saving.
*/
export function MedicineSuggestions({ canCreate, disabled, describedBy }: MedicineSuggestionsProps) {
const query = useQuery({ queryKey: ["medicine-suggestions"], queryFn: fetchMedicineSuggestions });
const suggestions = query.data ?? [];
if (suggestions.length === 0) {
return (
<EmptyState
icon={Pill}
title="Noch keine Tierarzneimittel"
description={
canCreate
? "Lege dein erstes Mittel mit „Mittel anlegen“ an. Danach kannst du Packungen erfassen und Behandlungen daraus buchen."
: "Hier erscheinen die Mittel, sobald jemand sie anlegt."
}
/>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Aus deinen Behandlungen übernehmen?</CardTitle>
<p className="text-sm text-muted-foreground">
Diese Mittel hast du schon angewendet. Du prüfst die Werte, bevor etwas gespeichert wird.
</p>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2">
{suggestions.map((suggestion) => (
<li
key={suggestion.name}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3"
>
<div className="min-w-0">
<p className="font-medium break-words">{suggestion.name}</p>
<p className="text-sm text-muted-foreground">
{suggestion.treatmentCount === 1
? "1 Behandlung"
: `${suggestion.treatmentCount} Behandlungen`}
</p>
</div>
{canCreate ? (
<MedicineFormDialog
prefill={prefillFromSuggestion(suggestion)}
triggerLabel="Übernehmen"
triggerAriaLabel={`Übernehmen: ${suggestion.name}`}
triggerVariant="outline"
disabled={disabled}
describedBy={describedBy}
/>
) : null}
</li>
))}
</ul>
</CardContent>
</Card>
);
}
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MedicinesPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
import { createMedicine, fetchMedicines, fetchMedicineSuggestions } from "@/lib/medicines/api";
import { medicine, suggestion } from "@/lib/medicines/fixtures.spec-helper";
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/medicines/api", () => ({
fetchMedicines: vi.fn(),
fetchMedicineSuggestions: vi.fn(),
createMedicine: vi.fn(),
updateMedicine: vi.fn(),
}));
function renderPage(permissions = ["veterinary-medicines:read", "veterinary-medicines:create"]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MedicinesPage />
</QueryClientProvider>,
);
}
describe("MedicinesPage", () => {
beforeEach(() => {
vi.mocked(fetchMedicines).mockReset();
vi.mocked(fetchMedicineSuggestions).mockReset();
vi.mocked(createMedicine).mockReset();
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([]);
});
it("lists name, rest and next expiry per medicine", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([
medicine({ totalRemaining: 12, nextExpiry: "2028-01-31" }),
]);
renderPage();
const card = (await screen.findByRole("link", { name: /Apivar/ })) as HTMLElement;
expect(card).toHaveAttribute("href", "/medicines/m1");
expect(within(card).getByText("12 Stück")).toBeInTheDocument();
expect(within(card).getByText("31.01.2028")).toBeInTheDocument();
});
it("shows a negative rest in red with the word Negativ", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]);
renderPage();
const negative = await screen.findByText(/Negativ/);
expect(negative.closest("span.text-destructive")).not.toBeNull();
expect(negative.closest("a")).toHaveTextContent("−2 ml");
});
it("offers the empty catalogue 'Aus deinen Behandlungen übernehmen?' with the suggestions", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([
suggestion(),
suggestion({ name: "Oxalsäure", treatmentCount: 1 }),
]);
renderPage();
expect(await screen.findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
expect(screen.getByText("Thymovar")).toBeInTheDocument();
expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
});
it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: /Übernehmen: Thymovar/ }));
expect(await screen.findByLabelText(/^Name/)).toHaveValue("Thymovar");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Thymol");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(7);
expect(screen.getByText(/Vorbelegt aus deiner letzten Behandlung/)).toBeInTheDocument();
expect(createMedicine).not.toHaveBeenCalled();
});
it("falls back to the plain empty state when there is nothing to suggest", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
renderPage();
expect(await screen.findByText("Noch keine Tierarzneimittel")).toBeInTheDocument();
expect(screen.queryByText("Aus deinen Behandlungen übernehmen?")).not.toBeInTheDocument();
});
it("the main action is Mittel anlegen, once in the header", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([medicine()]);
renderPage();
await screen.findByRole("link", { name: /Apivar/ });
expect(screen.getAllByRole("button", { name: "Mittel anlegen" })).toHaveLength(1);
});
it("hides create buttons without veterinary-medicines:create", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
renderPage(["veterinary-medicines:read"]);
await screen.findByText("Thymovar");
expect(screen.queryByRole("button", { name: /Mittel anlegen/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Übernehmen/ })).not.toBeInTheDocument();
});
it("announces the number of shown medicines in one short line instead of the whole list", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([medicine(), medicine({ id: "m2", name: "Thymovar" })]);
renderPage();
const count = await screen.findByText("2 aktive Mittel");
expect(count).toHaveAttribute("aria-live", "polite");
expect(document.querySelectorAll("[aria-live]").length).toBe(1);
});
it("shows no data and no retry without veterinary-medicines:read", async () => {
renderPage([]);
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchMedicines).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
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";
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 { RestText } from "@/components/medicines/rest-text";
import { Badge } from "@/components/ui/badge";
import { NoReadAccess } from "@/components/medicines/no-read-access";
import { MedicineFormDialog } from "./medicine-form-dialog";
import { MedicineSuggestions } from "./medicine-suggestions";
export default function MedicinesPage() {
const { hasPermission } = useAuth();
const canRead = hasPermission("veterinary-medicines:read");
const canCreate = hasPermission("veterinary-medicines:create");
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const medicinesQuery = useQuery({ queryKey: ["medicines"], queryFn: () => fetchMedicines(), enabled: canRead });
const medicines = medicinesQuery.data ?? [];
const active = medicines.filter((medicine) => medicine.active);
const inactive = medicines.filter((medicine) => !medicine.active);
const visible = tab === "active" ? active : inactive;
const countText = medicinesQuery.data
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
: "";
if (!canRead) return <NoReadAccess />;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Pill}
title="Tierarzneimittel"
description="Dein Katalog mit Packungen und dem Bewegungsbuch. Behandlungen buchen aus der Packung ab."
actions={canCreate ? <MedicineFormDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
/>
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
{medicines.length > 0 ? (
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={active.length}
historyCount={inactive.length}
/>
) : null}
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{medicines.length > 0 ? countText : ""}
</p>
<QueryState query={medicinesQuery} what="Tierarzneimittel">
{() =>
medicines.length === 0 ? (
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
) : visible.length === 0 ? (
<EmptyState
icon={Pill}
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
description={
tab === "active"
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
: "Deaktivierte Mittel erscheinen hier."
}
/>
) : (
<MedicineGrid medicines={visible} />
)
}
</QueryState>
</main>
);
}
function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{medicines.map((medicine) => (
<EntityCard
key={medicine.id}
href={`/medicines/${medicine.id}`}
icon={Pill}
title={medicine.name}
subtitle={medicine.activeIngredient ?? "—"}
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) },
]}
/>
))}
</div>
);
}
@@ -65,4 +65,14 @@ describe("nav-config", () => {
expect(item?.label).toBe("Vorschläge");
expect(item?.counter).toBe("suggestions");
});
it("shows Prüfbereitschaft only with compliance:read", () => {
expect(hrefs([])).not.toContain("/compliance");
expect(hrefs(["compliance:read"])).toContain("/compliance");
const group = visibleNavGroups({ permissions: ["compliance:read"] }).find((g) =>
g.items.some((item) => item.href === "/compliance"),
);
expect(group?.label).toBe("Betrieb");
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
});
});
@@ -18,6 +18,7 @@ import {
Package,
Scissors,
Search,
ShieldCheck,
ShoppingBag,
Snowflake,
Users,
@@ -70,6 +71,12 @@ export const NAV_GROUPS: NavGroup[] = [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Prüfbereitschaft",
href: "/compliance",
icon: ShieldCheck,
permission: "compliance:read",
},
{
label: "Überwinterung",
href: "/winter",
@@ -43,6 +43,7 @@ describe("ProductDetailPage", () => {
sku: null,
packagingSize: null,
shelfLifeMonths: null,
legalName: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -102,6 +103,7 @@ describe("ProductDetailPage", () => {
sku: null,
packagingSize: 250,
shelfLifeMonths: 18,
legalName: "Blütenhonig",
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -113,6 +115,7 @@ describe("ProductDetailPage", () => {
renderPage();
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
expect(screen.getByText("Bezeichnung auf dem Etikett: Blütenhonig")).toBeInTheDocument();
});
it("hides jar weight and shelf life when they are not set", async () => {
@@ -86,6 +86,7 @@ export default function ProductDetailPage() {
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
) : null}
{product.sku ? <p>SKU: {product.sku}</p> : null}
{product.legalName ? <p>Bezeichnung auf dem Etikett: {product.legalName}</p> : null}
{product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p>
) : null}
@@ -16,6 +16,7 @@ const PRODUCT: Product = {
sku: null,
packagingSize: 500,
shelfLifeMonths: 24,
legalName: "Blütenhonig",
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -108,4 +109,16 @@ describe("EditProductDialog", () => {
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht");
});
it("the product form shows, saves and clears the label name", async () => {
await openDialog();
const field = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
expect(field).toHaveValue("Blütenhonig");
await userEvent.clear(field);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ legalName: null });
});
});
@@ -35,6 +35,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [sku, setSku] = useState(product.sku ?? "");
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? "");
const [legalName, setLegalName] = useState(product.legalName ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const [packagingError, setPackagingError] = useState<string | null>(null);
const [shelfLifeError, setShelfLifeError] = useState<string | null>(null);
@@ -49,6 +50,7 @@ export function EditProductDialog({ product }: { product: Product }) {
setSku(product.sku ?? "");
setPackagingSize(product.packagingSize?.toString() ?? "");
setShelfLifeMonths(product.shelfLifeMonths?.toString() ?? "");
setLegalName(product.legalName ?? "");
setWeight(product.weight?.toString() ?? "");
setSalesUnit(product.salesUnit ?? "");
setStatus(product.status);
@@ -66,6 +68,7 @@ export function EditProductDialog({ product }: { product: Product }) {
sku: emptyToNull(sku),
packagingSize: emptyToNull(packagingSize, Number),
shelfLifeMonths: emptyToNull(shelfLifeMonths, Number),
legalName: emptyToNull(legalName),
weight: emptyToNull(weight, Number),
salesUnit: emptyToNull(salesUnit),
status,
@@ -204,6 +207,21 @@ export function EditProductDialog({ product }: { product: Product }) {
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-legal-name">
Bezeichnung auf dem Etikett (z. B. Blütenhonig)
</Label>
<Input
id="product-edit-legal-name"
maxLength={200}
aria-describedby="product-edit-legal-name-hint"
value={legalName}
onChange={(event) => setLegalName(event.target.value)}
/>
<p id="product-edit-legal-name-hint" className="text-xs text-muted-foreground">
Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
<Input
@@ -60,6 +60,7 @@ describe("NewProductPage", () => {
weight: null,
salesUnit: null,
shelfLifeMonths: null,
legalName: null,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
});
@@ -115,4 +116,17 @@ describe("NewProductPage", () => {
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
expect(createProduct).not.toHaveBeenCalled();
});
it("saves the label name of the product trimmed", async () => {
vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
const legalName = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
expect(legalName).toHaveAttribute("maxlength", "200");
await userEvent.type(legalName, " Blütenhonig ");
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ legalName: "Blütenhonig" });
});
});
@@ -165,6 +165,20 @@ export default function NewProductPage() {
/>
)}
</Field>
<Field
label="Bezeichnung auf dem Etikett (z. B. Blütenhonig)"
hint="Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort."
>
{({ id, describedBy }) => (
<Input
id={id}
maxLength={200}
aria-describedby={describedBy}
value={values.legalName}
onChange={(event) => set("legalName", event.target.value)}
/>
)}
</Field>
<Field
label="Gewicht pro Glas (g)"
hint="Honig pro Glas in Gramm, z. B. 500. Die Abfüllung übernimmt den Wert."
@@ -12,6 +12,7 @@ export interface ProductFormValues {
salesUnit: string;
packagingSize: string;
shelfLifeMonths: string;
legalName: string;
weight: string;
sku: string;
description: string;
@@ -27,6 +28,7 @@ export function initialProductValues(): ProductFormValues {
salesUnit: "",
packagingSize: "",
shelfLifeMonths: "",
legalName: "",
weight: "",
sku: "",
description: "",
@@ -53,6 +55,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
sku: emptyToNull(values.sku),
packagingSize: emptyToNull(values.packagingSize, Number),
shelfLifeMonths: emptyToNull(values.shelfLifeMonths, Number),
legalName: emptyToNull(values.legalName),
weight: emptyToNull(values.weight, Number),
salesUnit: emptyToNull(values.salesUnit),
kind: values.kind,
@@ -20,6 +20,8 @@ const settings: SiteSettings = {
websiteUrl: null,
holderName: null,
holderAddress: null,
foodBusinessName: null,
foodBusinessAddress: null,
originCountry: "Deutschland",
updatedAt: "2026-10-13T00:00:00.000Z",
};
@@ -68,3 +70,71 @@ describe("SocialMediaTab origin country", () => {
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
});
});
describe("SocialMediaTab food business", () => {
beforeEach(() => {
vi.mocked(fetchSiteSettings).mockReset();
vi.mocked(updateSiteSettings).mockReset();
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
});
it("shows the saved food business fields with their limits", async () => {
vi.mocked(fetchSiteSettings).mockResolvedValue({
...settings,
foodBusinessName: "Imkerei Muster",
foodBusinessAddress: "Bühlweg 10, 86450 Altenmünster",
});
renderTab();
const name = await screen.findByLabelText(/^Name des Lebensmittelunternehmers/);
expect(name).toHaveValue("Imkerei Muster");
expect(name).toHaveAttribute("maxlength", "200");
const address = screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/);
expect(address).toHaveValue("Bühlweg 10, 86450 Altenmünster");
expect(address).toHaveAttribute("maxlength", "300");
});
it("Vom Bienenhalter übernehmen fills the fields visibly and saves only on Speichern", 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.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Max Imker");
expect(screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/)).toHaveValue(
"Dorfstraße 1, 12345 Ort",
);
expect(screen.getByRole("status")).toHaveTextContent(/noch nicht gespeichert/);
expect(updateSiteSettings).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
foodBusinessName: "Max Imker",
foodBusinessAddress: "Dorfstraße 1, 12345 Ort",
});
});
it("does not overwrite the fields when the beekeeper has no data", async () => {
vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Eigener Name" });
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
expect(screen.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Eigener Name");
expect(screen.getByRole("status")).toHaveTextContent(/nichts eingetragen/);
});
it("saves emptied or blank food business fields as null so the server clears them", async () => {
vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Alt" });
renderTab();
await userEvent.clear(await screen.findByLabelText(/^Name des Lebensmittelunternehmers/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null });
});
});
@@ -2,9 +2,11 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserRound } from "lucide-react";
import { toast } from "sonner";
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
import { SiteSettings } from "@/lib/site-settings/types";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -21,6 +23,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
const [foodBusinessName, setFoodBusinessName] = useState(settings.foodBusinessName ?? "");
const [foodBusinessAddress, setFoodBusinessAddress] = useState(
settings.foodBusinessAddress ?? "",
);
const [adoptNote, setAdoptNote] = useState<string | null>(null);
const [originCountry, setOriginCountry] = useState(settings.originCountry);
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
@@ -30,9 +37,21 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
toast.success("Website-Einstellungen gespeichert");
},
onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
onError: (cause) =>
toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")),
});
// Fills the form fields only; nothing is saved until „Speichern“.
function adoptFromHolder() {
if (holderName.trim() === "" && holderAddress.trim() === "") {
setAdoptNote("Beim Bienenhalter ist noch nichts eingetragen.");
return;
}
setFoodBusinessName(holderName);
setFoodBusinessAddress(holderAddress);
setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern.");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// NOT NULL in the database: it can be changed, not emptied.
@@ -47,6 +66,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
websiteUrl,
holderName,
holderAddress,
foodBusinessName: foodBusinessName.trim() || null,
foodBusinessAddress: foodBusinessAddress.trim() || null,
originCountry: originCountry.trim(),
});
}
@@ -141,6 +162,52 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
/>
</div>
</fieldset>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Lebensmittelunternehmer (für das Etikett)</legend>
<p className="text-xs text-muted-foreground">
Name und Anschrift stehen als Pflichtangabe auf jedem Honigetikett. Sie werden nicht
im Shop und nicht auf der Herkunftsseite angezeigt.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="site-food-business-name">Name des Lebensmittelunternehmers</Label>
<Input
id="site-food-business-name"
maxLength={200}
value={foodBusinessName}
onChange={(event) => {
setFoodBusinessName(event.target.value);
setAdoptNote(null);
}}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="site-food-business-address">
Anschrift des Lebensmittelunternehmers
</Label>
<Input
id="site-food-business-address"
maxLength={300}
value={foodBusinessAddress}
onChange={(event) => {
setFoodBusinessAddress(event.target.value);
setAdoptNote(null);
}}
/>
</div>
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
onClick={adoptFromHolder}
>
<UserRound className="size-4" />
Vom Bienenhalter übernehmen
</Button>
<p role="status" className="text-sm text-muted-foreground">
{adoptNote}
</p>
</fieldset>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
@@ -40,6 +40,7 @@ export default function EditLabelTemplatePage() {
elements: template.elements,
widthMm: template.widthMm,
heightMm: template.heightMm,
minXHeightMm: template.minXHeightMm,
columns: template.columns,
rows: template.rows,
marginTopMm: template.marginTopMm,
@@ -1,13 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LabelDesigner } from "./label-designer";
import { toast } from "sonner";
import { apiFetch } from "@/lib/auth/api-client";
import type { LabelCheckResult } from "@/lib/label-templates/check";
import type { LabelElement } from "@/lib/label-templates/types";
import { LabelDesigner, type LabelDesignerValue } from "./label-designer";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
const RESULT: LabelCheckResult = {
printable: false,
gaps: [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "Fehlt auf dem Etikett.", elementIds: [] }],
warnings: [],
sample: true,
// Deliberately different from the stored value: the hint must show this one.
requirements: { minXHeightMm: 2.3, netQuantityMinCapHeightMm: 3 },
};
const TEXT: LabelElement = {
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",
};
const INITIAL: LabelDesignerValue = {
name: "Etikett",
elements: [TEXT, { id: "q1", type: "qrCode", x: 50, y: 1, size: 10 }],
widthMm: 90,
heightMm: 35,
minXHeightMm: 1.7,
columns: null,
rows: null,
marginTopMm: 5,
marginLeftMm: 5,
gapXMm: 2,
gapYMm: 2,
cropMarks: true,
isDefault: false,
};
function sentBodies(): { elements: { id: string }[]; minXHeightMm: number }[] {
return vi
.mocked(apiFetch)
.mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body)));
}
describe("LabelDesigner placeholder menu", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(RESULT);
});
describe("LabelDesigner", () => {
it("keeps the placeholder menu labelled as an action after inserting, never showing the raw key", async () => {
render(<LabelDesigner onSubmit={vi.fn()} submitLabel="Anlegen" />);
const insertMenu = screen.getByText("Platzhalter einfügen").closest('[role="combobox"]');
@@ -25,3 +89,137 @@ describe("LabelDesigner", () => {
expect(insertMenu).not.toHaveTextContent("Chargennummer");
});
});
describe("LabelDesigner compliance check", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(RESULT);
vi.mocked(toast).mockReset();
});
afterEach(() => vi.useRealTimers());
it("debounces the check by 500 ms and sends text elements only", async () => {
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
await act(async () => {
vi.advanceTimersByTime(499);
});
expect(apiFetch).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/label-templates/check");
expect(sentBodies()[0].elements.map((element) => element.id)).toEqual(["t1"]);
expect(sentBodies()[0].minXHeightMm).toBe(INITIAL.minXHeightMm);
expect(screen.getByText("Fehlt auf dem Etikett.")).toBeInTheDocument();
});
it("Pflichtangaben-Block einfügen adds the block and Rückgängig removes it again", async () => {
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
await act(async () => {
vi.advanceTimersByTime(500);
});
const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
expect(insert).not.toHaveAttribute("aria-disabled", "true");
await act(async () => {
insert.click();
});
await act(async () => {
vi.advanceTimersByTime(500);
});
// The next check carries the block: the original text plus one element per new placeholder and the net quantity.
const afterInsert = sentBodies().at(-1)!.elements;
expect(afterInsert.length).toBeGreaterThan(1);
const toastCall = vi.mocked(toast).mock.calls.at(-1)!;
const action = (toastCall[1] as { action: { label: string; onClick: () => void } }).action;
expect(action.label).toBe("Rückgängig");
await act(async () => {
action.onClick();
});
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(sentBodies().at(-1)!.elements.map((element) => element.id)).toEqual(["t1"]);
});
it("keeps the insert button inactive with a reason until the first check is back", async () => {
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
expect(insert).toHaveAttribute("aria-disabled", "true");
const reason = document.getElementById(insert.getAttribute("aria-describedby") ?? "");
expect(reason).toHaveTextContent(/Prüfung/);
});
it("shows the stored minimum and the one the check demands, and states no legal number itself", async () => {
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("1,7");
const hint = document.getElementById("designer-min-x-height-hint")!;
expect(hint).toHaveTextContent(/gesetzlichen Mindestwert/);
expect(hint).not.toHaveTextContent(/mm/);
await act(async () => {
vi.advanceTimersByTime(500);
});
// The number comes from the check result, not from the frontend.
expect(hint).toHaveTextContent("2,3 mm");
});
it("leaves the field empty for a new template and sends no minimum on create", async () => {
vi.useRealTimers();
const onSubmit = vi.fn().mockResolvedValue(undefined);
const { minXHeightMm: stored, ...withoutMin } = INITIAL;
void stored;
render(<LabelDesigner initial={withoutMin} onSubmit={onSubmit} submitLabel="Anlegen" />);
expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("");
expect(screen.getByLabelText(/Mindest-x-Höhe/)).not.toHaveAttribute("aria-invalid", "true");
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("minXHeightMm");
expect(apiFetch).not.toHaveBeenCalled();
});
it("the minimum x-height field accepts the technical range 0.5 to 5", async () => {
vi.useRealTimers();
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
const field = screen.getByLabelText(/Mindest-x-Höhe/);
for (const valid of ["0,5", "0.5", "5", "2,75"]) {
await userEvent.clear(field);
await userEvent.type(field, valid);
expect(field).not.toHaveAttribute("aria-invalid", "true");
}
for (const invalid of ["0,4", "5,1", "0", "-1", "abc"]) {
await userEvent.clear(field);
await userEvent.type(field, invalid);
expect(field).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/zwischen 0,5 und 5 mm/)).toBeInTheDocument();
}
});
it("saves the minimum x-height and refuses to save an invalid one", async () => {
vi.useRealTimers();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<LabelDesigner initial={INITIAL} onSubmit={onSubmit} submitLabel="Speichern" />);
const field = screen.getByLabelText(/Mindest-x-Höhe/);
await userEvent.clear(field);
await userEvent.type(field, "9");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(onSubmit).not.toHaveBeenCalled();
expect(field).toHaveFocus();
await userEvent.clear(field);
await userEvent.type(field, "0,9");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 }));
});
});
@@ -1,12 +1,13 @@
"use client";
import { ChangeEvent, useEffect, useState } from "react";
import { ChangeEvent, useEffect, useId, useState } from "react";
import { useRouter } from "next/navigation";
import {
Download,
Image as ImageIcon,
Minus,
QrCode,
ShieldCheck,
Type,
Upload,
ZoomIn,
@@ -20,6 +21,12 @@ import {
PLACEHOLDER_LABELS,
PlaceholderKey,
} from "@/lib/label-templates/types";
import {
MIN_X_HEIGHT_RANGE_MM,
useDebouncedLabelCheck,
} from "@/lib/label-templates/check";
import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -40,6 +47,18 @@ const A4_HEIGHT_MM = 297;
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
const ZOOM_MIN = ZOOM_STEPS[0];
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
const MIN_X_HEIGHT_ERROR = "Bitte einen Wert zwischen 0,5 und 5 mm eingeben.";
function formatDecimal(value: number): string {
return String(value).replace(".", ",");
}
/** German or English decimal sign; `null` for empty input or outside the range the server accepts. */
function parseMinXHeight(text: string): number | null {
const value = Number(text.trim().replace(",", "."));
if (text.trim() === "" || !Number.isFinite(value)) return null;
return value >= MIN_X_HEIGHT_RANGE_MM.min && value <= MIN_X_HEIGHT_RANGE_MM.max ? value : null;
}
function nextZoomStep(current: number, direction: 1 | -1): number {
const index = ZOOM_STEPS.indexOf(current);
@@ -76,6 +95,8 @@ export interface LabelDesignerValue {
elements: LabelElement[];
widthMm: number;
heightMm: number;
/** Absent on a new template: the server sets its default on create. */
minXHeightMm?: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
@@ -141,6 +162,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const [name, setName] = useState(initial?.name ?? "");
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
const [minXHeightText, setMinXHeightText] = useState(
initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
);
const [columns, setColumns] = useState<number | null>(initial?.columns ?? null);
const [rows, setRows] = useState<number | null>(initial?.rows ?? null);
const [marginTopMm, setMarginTopMm] = useState(initial?.marginTopMm ?? 5);
@@ -155,6 +179,16 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const [zoom, setZoom] = useState(1);
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
const insertHintId = useId();
const minXHeightMm = parseMinXHeight(minXHeightText);
const minXHeightInvalid = minXHeightText.trim() !== "" && minXHeightMm === null;
const check = useDebouncedLabelCheck({
elements,
widthMm,
heightMm,
minXHeightMm: minXHeightMm ?? Number.NaN,
});
const canInsertBlock = check.result !== null;
// Mirrors the backend's clamp in label-templates.service.ts exactly, so
// the hint shown here always matches what actually prints - a value typed
@@ -205,6 +239,27 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
});
}
// The block is a start, not a finished layout: it lands on the label and can
// be taken back in one step.
function insertComplianceBlock() {
if (!check.result) return;
const block = buildComplianceBlock({ widthMm, heightMm }, check.result.requirements);
const ids = new Set(block.map((element) => element.id));
setElements((current) => [...current, ...block]);
setSelectedId(block[0]?.id ?? null);
toast("Pflichtangaben-Block eingefügt", {
description: "Ordne die Felder auf dem Etikett an.",
action: {
label: "Rückgängig",
onClick: () => {
setElements((current) => current.filter((element) => !ids.has(element.id)));
setSelectedId((current) => (current !== null && ids.has(current) ? null : current));
},
},
duration: 8000,
});
}
function duplicateElement(id: string) {
const source = elements.find((element) => element.id === id);
if (!source) return;
@@ -231,12 +286,13 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
}, [selectedId, elements]);
// Leaving the page (tab close, reload) with unsaved changes asks first.
const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm });
const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm, minXHeightText });
const initialSnapshot = JSON.stringify({
name: initial?.name ?? "",
elements: initial?.elements ?? [],
widthMm: initial?.widthMm ?? 90,
heightMm: initial?.heightMm ?? 35,
minXHeightText: initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
});
const hasUnsavedChanges = currentSnapshot !== initialSnapshot;
useEffect(() => {
@@ -277,6 +333,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
elements,
widthMm,
heightMm,
...(minXHeightMm === null ? {} : { minXHeightMm }),
columns,
rows,
marginTopMm,
@@ -320,6 +377,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
if (typeof value.name === "string" && value.name && !name) setName(value.name);
if (typeof value.widthMm === "number") setWidthMm(value.widthMm);
if (typeof value.heightMm === "number") setHeightMm(value.heightMm);
if (typeof value.minXHeightMm === "number") {
setMinXHeightText(formatDecimal(value.minXHeightMm));
}
if (typeof value.columns === "number" || value.columns === null) setColumns(value.columns);
if (typeof value.rows === "number" || value.rows === null) setRows(value.rows);
if (typeof value.marginTopMm === "number") setMarginTopMm(value.marginTopMm);
@@ -342,6 +402,10 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
toast.error("Bitte einen Namen für die Vorlage angeben.");
return;
}
if (minXHeightInvalid) {
document.getElementById("designer-min-x-height")?.focus();
return;
}
setIsSubmitting(true);
try {
await onSubmit({
@@ -349,6 +413,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
elements,
widthMm,
heightMm,
...(minXHeightMm === null ? {} : { minXHeightMm }),
columns,
rows,
marginTopMm,
@@ -405,6 +470,33 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="designer-min-x-height">Mindest-x-Höhe (mm)</Label>
<Input
id="designer-min-x-height"
type="text"
inputMode="decimal"
className="min-h-11 sm:h-11"
value={minXHeightText}
aria-invalid={minXHeightInvalid ? true : undefined}
aria-describedby="designer-min-x-height-hint"
onChange={(event) => setMinXHeightText(event.target.value)}
/>
<p id="designer-min-x-height-hint" className="text-xs text-muted-foreground">
Der Wert muss den gesetzlichen Mindestwert erreichen.
{check.result
? ` Die Prüfung verlangt aktuell mindestens ${check.result.requirements.minXHeightMm.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm.`
: ""}
{minXHeightText.trim() === ""
? " Leer lassen: Der Server setzt den Standardwert. Die Prüfung läuft, sobald hier ein Wert steht."
: ""}
</p>
{minXHeightInvalid ? (
<p role="alert" className="text-sm font-medium text-destructive">
{MIN_X_HEIGHT_ERROR}
</p>
) : null}
</div>
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
<input
id="designer-default"
@@ -538,6 +630,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="ghost"
size="sm"
className="min-h-11"
render={
<label className="cursor-pointer">
<Upload className="size-4" />
@@ -551,7 +644,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</label>
}
/>
<Button type="button" variant="ghost" size="sm" onClick={handleExport}>
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
<Download className="size-4" />
Exportieren
</Button>
@@ -562,6 +655,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() => addElement(newTextElement())}
>
<Type className="size-4" />
@@ -577,7 +671,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
if (key) handlePlaceholderInsert(key);
}}
>
<SelectTrigger className="w-auto" size="sm" aria-label="Platzhalter einfügen">
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
<SelectValue>Platzhalter einfügen</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -592,6 +686,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
}
@@ -603,6 +698,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={() =>
addElement({
id: newId(),
@@ -619,16 +715,42 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
<Minus className="size-4" />
Linie
</Button>
<Button type="button" variant="outline" size="sm" render={
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
<label className="cursor-pointer">
<ImageIcon className="size-4" />
Bild/Logo
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
</label>
} />
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
aria-disabled={canInsertBlock ? undefined : true}
aria-describedby={canInsertBlock ? undefined : insertHintId}
onClick={() => {
if (canInsertBlock) insertComplianceBlock();
}}
>
<ShieldCheck className="size-4" />
Pflichtangaben-Block einfügen
</Button>
{canInsertBlock ? null : (
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
Geht, sobald die erste Prüfung da ist.
</p>
)}
</CardContent>
</Card>
<LabelCompliancePanel
result={check.result}
pending={check.pending}
error={check.error}
stale={check.stale}
/>
<div className="flex items-center gap-2">
<Button
type="button"
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import StoragePage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/storage/api", () => ({
fetchStorageContainers: vi.fn(async () => []),
fetchStockItems: vi.fn(async () => []),
}));
vi.mock("./create-storage-container-dialog", () => ({ CreateStorageContainerDialog: () => null }));
vi.mock("./create-stock-item-dialog", () => ({ CreateStockItemDialog: () => null }));
vi.mock("./edit-storage-container-dialog", () => ({ EditStorageContainerDialog: () => null }));
vi.mock("./edit-stock-item-dialog", () => ({ EditStockItemDialog: () => null }));
vi.mock("./record-movement-dialog", () => ({ RecordMovementDialog: () => null }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<StoragePage />
</QueryClientProvider>,
);
}
describe("StoragePage", () => {
it("shows the Tierarzneimittel card with a link to /medicines with veterinary-medicines:read", async () => {
renderPage(["veterinary-medicines:read"]);
const link = await screen.findByRole("link", { name: /Tierarzneimittel/ });
expect(link).toHaveAttribute("href", "/medicines");
});
it("does not show the Tierarzneimittel card without veterinary-medicines:read", async () => {
renderPage([]);
await screen.findByText("Noch keine Lagerbehälter");
expect(screen.queryByRole("link", { name: /Tierarzneimittel/ })).not.toBeInTheDocument();
});
});
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { AlertTriangle, Package, Warehouse } from "lucide-react";
import { AlertTriangle, Package, Pill, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
import { StockItem, StorageContainer } from "@/lib/storage/types";
import { STOCK_CATEGORY_LABELS, STORAGE_CONTAINER_STATUS_LABELS } from "@/lib/storage/labels";
@@ -20,6 +21,7 @@ import { EditStockItemDialog } from "./edit-stock-item-dialog";
import { RecordMovementDialog } from "./record-movement-dialog";
export default function StoragePage() {
const { hasPermission } = useAuth();
const [containerTab, setContainerTab] = useState<EntityLifecycleTab>("active");
const containersQuery = useQuery({
queryKey: ["storage-containers"],
@@ -45,6 +47,7 @@ export default function StoragePage() {
gradient="var(--gradient-sky)"
actions={<CreateStorageContainerDialog />}
/>
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
<ActiveHistoryTabs
@@ -94,6 +97,20 @@ export default function StoragePage() {
);
}
function MedicinesCard() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard
href="/medicines"
icon={Pill}
gradient="var(--gradient-sky)"
title="Tierarzneimittel"
subtitle="Katalog, Packungen und Bewegungsbuch"
/>
</div>
);
}
function ContainersEmpty({ tab }: { tab: EntityLifecycleTab }) {
if (tab === "history") {
return (