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 (
@@ -133,4 +133,20 @@ describe("DocumentUploadCard", () => {
);
expect(outerSubmit).not.toHaveBeenCalled();
});
it("offers no upload, rename or delete when canWrite is false, but still downloads", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<DocumentUploadCard entityType="INSPECTION" entityId="i-1" canWrite={false} />
</QueryClientProvider>,
);
expect(await screen.findByText("rahmen.jpg")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Herunterladen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Datei hochladen/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Umbenennen/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Löschen" })).not.toBeInTheDocument();
});
});
@@ -150,12 +150,15 @@ interface DocumentUploadCardProps {
entityType: DocumentEntityType;
entityId: string;
title?: string;
/** False: only look at and download the files (no upload, rename or delete). */
canWrite?: boolean;
}
export function DocumentUploadCard({
entityType,
entityId,
title = "Dokumente",
canWrite = true,
}: DocumentUploadCardProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -205,22 +208,26 @@ export function DocumentUploadCard({
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
{uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
{canWrite ? (
<>
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
{uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
</>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
@@ -228,7 +235,9 @@ export function DocumentUploadCard({
<EmptyState
icon={Paperclip}
title="Noch keine Dateien"
description="Lade Fotos oder Dokumente hoch, um sie hier zu sammeln."
description={
canWrite ? "Lade Fotos oder Dokumente hoch, um sie hier zu sammeln." : "Hier liegen noch keine Dateien."
}
/>
) : null}
{documents?.map((document) => (
@@ -258,8 +267,8 @@ export function DocumentUploadCard({
>
Herunterladen
</Button>
<RenameDocumentDialog document={document} />
<ConfirmDialog
{canWrite ? <RenameDocumentDialog document={document} /> : null}
{canWrite ? <ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
@@ -269,7 +278,7 @@ export function DocumentUploadCard({
confirmLabel="Löschen"
isPending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(document.id)}
/>
/> : null}
</div>
</div>
))}
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { LabelCompliancePanel } from "./label-compliance-panel";
import type { LabelCheckResult, LabelFinding } from "@/lib/label-templates/check";
function finding(code: LabelFinding["code"], title: string, detail = ""): LabelFinding {
return { code, title, detail, elementIds: [] };
}
const COMPLETE: LabelCheckResult = {
printable: true,
gaps: [],
warnings: [],
sample: false,
requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: 3 },
};
function items(): HTMLElement[] {
return within(screen.getByRole("list", { name: "Pflichtangaben" })).getAllByRole("listitem");
}
describe("LabelCompliancePanel", () => {
it("shows each particular with a check mark or a cross and the word Erfüllt or Fehlt", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
printable: false,
gaps: [
finding("ORIGIN", "Herkunftsland fehlt", "Das Herkunftsland steht nicht auf dem Etikett."),
finding("LOT", "Losnummer fehlt"),
],
}}
pending={false}
error={null}
/>,
);
expect(items()).toHaveLength(6);
const origin = items().find((item) => /Herkunftsland/.test(item.textContent ?? ""))!;
expect(origin).toHaveTextContent("Fehlt");
expect(origin).toHaveTextContent("Das Herkunftsland steht nicht auf dem Etikett.");
expect(origin.querySelector("svg")).not.toBeNull();
const legalName = items().find((item) => /Verkehrsbezeichnung/.test(item.textContent ?? ""))!;
expect(legalName).toHaveTextContent("Erfüllt");
expect(legalName).not.toHaveTextContent("Fehlt");
expect(legalName.querySelector("svg")).not.toBeNull();
expect(items().filter((item) => /Fehlt/.test(item.textContent ?? ""))).toHaveLength(2);
expect(items().filter((item) => /Erfüllt/.test(item.textContent ?? ""))).toHaveLength(4);
});
it("lists warnings separately and announces changes via aria-live polite", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
warnings: [
finding("X_HEIGHT", "Text zu klein (x-Höhe 1,1 mm)", "Mindestens 1,2 mm."),
finding("TEXT_CLIPPED", "Text abgeschnitten"),
],
}}
pending={false}
error={null}
/>,
);
const warnings = screen.getByRole("list", { name: "Hinweise" });
expect(within(warnings).getAllByRole("listitem")).toHaveLength(2);
expect(within(warnings).getByText("Hinweis: Text zu klein (x-Höhe 1,1 mm)")).toBeInTheDocument();
// The warnings are not part of the particulars list.
expect(items()).toHaveLength(6);
// Changes are announced through the summary line only.
expect(screen.getByText("Alle Pflichtangaben sind erfüllt.")).toHaveAttribute("aria-live", "polite");
});
it('shows "Beispielwerte" when the result is a sample', () => {
render(<LabelCompliancePanel result={{ ...COMPLETE, sample: true }} pending={false} error={null} />);
expect(screen.getByText(/Beispielwerte/)).toBeInTheDocument();
});
it("does not mention sample values for a real bottling", () => {
render(<LabelCompliancePanel result={COMPLETE} pending={false} error={null} />);
expect(screen.queryByText(/Beispielwerte/)).not.toBeInTheDocument();
});
it("takes the legal minimums from the result and states none of its own", () => {
render(
<LabelCompliancePanel
result={{ ...COMPLETE, requirements: { minXHeightMm: 1.7, netQuantityMinCapHeightMm: 4 } }}
pending={false}
error={null}
/>,
);
expect(screen.getByText(/1,7 mm/)).toBeInTheDocument();
expect(screen.getByText(/4 mm/)).toBeInTheDocument();
});
it("keeps the live region while the check runs and says so", () => {
const { container } = render(<LabelCompliancePanel result={null} pending error={null} />);
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
expect(screen.getByText(/wird geprüft/i)).toBeInTheDocument();
});
it("puts only the summary line in the live region", () => {
const { container } = render(
<LabelCompliancePanel
result={{
...COMPLETE,
printable: false,
gaps: [finding("LOT", "Losnummer fehlt")],
warnings: [finding("TEXT_CLIPPED", "Text abgeschnitten")],
}}
pending={false}
error={null}
/>,
);
const live = container.querySelectorAll('[aria-live="polite"]');
expect(live).toHaveLength(1);
expect(live[0]).toHaveTextContent("Es fehlt 1 Pflichtangabe.");
expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Pflichtangaben" }));
expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Hinweise" }));
});
it("prefixes every warning with Hinweis:", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
warnings: [finding("X_HEIGHT", "Text zu klein"), finding("TEXT_CLIPPED", "Text abgeschnitten")],
}}
pending={false}
error={null}
/>,
);
const items = within(screen.getByRole("list", { name: "Hinweise" })).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("Hinweis: Text zu klein");
expect(items[1]).toHaveTextContent("Hinweis: Text abgeschnitten");
});
it("says the result is veraltet when it answers an older draft", () => {
render(<LabelCompliancePanel result={COMPLETE} pending={false} error={null} stale />);
expect(screen.getByText(/Veraltet/)).toBeInTheDocument();
});
it("shows an error as an alert", () => {
render(<LabelCompliancePanel result={null} pending={false} error="Die Prüfung ist fehlgeschlagen." />);
expect(screen.getByRole("alert")).toHaveTextContent("Die Prüfung ist fehlgeschlagen.");
});
});
@@ -0,0 +1,142 @@
"use client";
import { CircleCheck, CircleX, Loader, TriangleAlert } from "lucide-react";
import {
PARTICULAR_CODES,
type LabelCheckResult,
type LabelFinding,
type LabelFindingCode,
} from "@/lib/label-templates/check";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const PARTICULAR_LABELS: Record<(typeof PARTICULAR_CODES)[number], string> = {
LEGAL_NAME: "Verkehrsbezeichnung",
NET_QUANTITY: "Füllmenge",
BEST_BEFORE: "Mindesthaltbarkeitsdatum",
LOT: "Losnummer",
ORIGIN: "Herkunftsland",
FOOD_BUSINESS: "Name und Anschrift des Lebensmittelunternehmers",
};
function formatMm(value: number): string {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm`;
}
function summaryOf(result: LabelCheckResult | null, pending: boolean): string | null {
if (pending) return "Das Etikett wird geprüft …";
if (!result) return null;
const missing = result.gaps.length;
if (missing === 0) return "Alle Pflichtangaben sind erfüllt.";
return missing === 1 ? "Es fehlt 1 Pflichtangabe." : `Es fehlen ${missing} Pflichtangaben.`;
}
function ParticularRow({ code, gap }: { code: LabelFindingCode; gap: LabelFinding | undefined }) {
const label = PARTICULAR_LABELS[code as keyof typeof PARTICULAR_LABELS];
return (
<li className="flex items-start gap-2 text-sm">
{gap ? (
<CircleX aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
) : (
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-success" />
)}
<span className="flex flex-col">
<span>
<span className="font-medium">{label}:</span>{" "}
<span className={gap ? "font-medium text-destructive" : "text-muted-foreground"}>
{gap ? "Fehlt" : "Erfüllt"}
</span>
</span>
{gap ? <span className="text-muted-foreground">{gap.detail || gap.title}</span> : null}
</span>
</li>
);
}
interface LabelCompliancePanelProps {
result: LabelCheckResult | null;
pending: boolean;
error: string | null;
/** `result` answers an older draft; the current one cannot be checked. */
stale?: boolean;
}
/**
* Shows the answer of the label check: the six particulars with symbol and
* word, the warnings apart (they never block printing) and the minimums the
* server used. The panel decides nothing itself.
*/
export function LabelCompliancePanel({
result,
pending,
error,
stale = false,
}: LabelCompliancePanelProps) {
const summary = stale
? "Veraltet: Dieser Stand passt nicht mehr zu deinen Eingaben."
: summaryOf(result, pending);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Pflichtangaben</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<div className="flex flex-col gap-3">
{summary ? (
<p aria-live="polite" className="flex items-center gap-2 text-sm font-medium">
{pending ? <Loader aria-hidden className="size-4 animate-spin motion-reduce:animate-none" /> : null}
{summary}
</p>
) : null}
{result ? (
<>
{result.sample ? (
<p className="text-sm text-muted-foreground">
Geprüft mit Beispielwerten, nicht mit einer echten Abfüllung.
</p>
) : null}
<ul aria-label="Pflichtangaben" className="flex flex-col gap-2">
{PARTICULAR_CODES.map((code) => (
<ParticularRow
key={code}
code={code}
gap={result.gaps.find((gap) => gap.code === code)}
/>
))}
</ul>
<p className="text-xs text-muted-foreground">
Mindest-x-Höhe: {formatMm(result.requirements.minXHeightMm)}
{result.requirements.netQuantityMinCapHeightMm !== null
? `. Ziffernhöhe der Füllmenge: mindestens ${formatMm(result.requirements.netQuantityMinCapHeightMm)}`
: ""}
.
</p>
{result.warnings.length > 0 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Hinweise (sie sperren den Druck nicht)</p>
<ul aria-label="Hinweise" className="flex flex-col gap-2">
{result.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 className="flex flex-col">
<span className="font-medium">Hinweis: {warning.title}</span>
{warning.detail ? (
<span className="text-muted-foreground">{warning.detail}</span>
) : null}
</span>
</li>
))}
</ul>
</div>
) : null}
</>
) : null}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,18 @@
import Link from "next/link";
/** Shown instead of a page whose data needs `veterinary-medicines:read`: no retry, it would not help. */
export function NoReadAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
<Link
href="/storage"
className="inline-flex min-h-11 items-center self-start text-sm underline underline-offset-2"
>
Zurück zum Lager
</Link>
</main>
);
}
@@ -0,0 +1,20 @@
import { AlertTriangle } from "lucide-react";
import { formatQuantity } from "@/lib/medicines/format";
import type { MedicineUnit } from "@/lib/medicines/types";
/**
* A rest of a medicine. Below zero it is red AND carries a symbol and the
* word "Negativ": the treatment happened, the book has a gap, and the state
* must not rely on colour alone.
*/
export function RestText({ value, unit }: { value: number; unit: MedicineUnit }) {
if (value < 0) {
return (
<span className="inline-flex max-w-full items-start gap-1 font-semibold whitespace-normal text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
{formatQuantity(value, unit)} · Negativ
</span>
);
}
return <span>{formatQuantity(value, unit)}</span>;
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ComplianceLine } from "./compliance-line";
import userEvent from "@testing-library/user-event";
import { ApiError } from "@/lib/auth/api-client";
import { fetchReadinessSummary } from "@/lib/compliance/api";
let permissions = ["compliance:read"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/compliance/api", () => ({ fetchReadinessSummary: vi.fn() }));
function renderLine() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ComplianceLine />
</QueryClientProvider>,
);
}
describe("ComplianceLine", () => {
beforeEach(() => {
permissions = ["compliance:read"];
vi.mocked(fetchReadinessSummary).mockReset();
});
it("renders only when openCount > 0 and the user holds compliance:read", async () => {
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 2, firstItemKey: "holder-data" });
renderLine();
const link = await screen.findByRole("link", { name: /2 Punkte offen/ });
expect(link).toHaveAttribute("href", "/compliance");
expect(link).toHaveTextContent("Prüfbereitschaft");
});
it("stays away when nothing is open", async () => {
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null });
const { container } = renderLine();
await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("does not even ask without compliance:read", () => {
permissions = [];
const { container } = renderLine();
expect(fetchReadinessSummary).not.toHaveBeenCalled();
expect(container).toBeEmptyDOMElement();
});
it("says quietly that it was not loaded and offers a retry", async () => {
vi.mocked(fetchReadinessSummary).mockRejectedValueOnce(new ApiError(500, "Serverfehler"));
renderLine();
expect(await screen.findByText("Prüfbereitschaft nicht geladen.")).toBeInTheDocument();
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 1, firstItemKey: "k" });
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("link", { name: /1 Punkt offen/ })).toBeInTheDocument();
});
it("stays away when the device is offline", async () => {
vi.mocked(fetchReadinessSummary).mockRejectedValue(new TypeError("Failed to fetch"));
const { container } = renderLine();
await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { ChevronRight, RotateCw, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { useAuth } from "@/lib/auth/auth-context";
import { useReadinessSummary } from "@/lib/compliance/use-readiness";
/**
* One line on the dashboard when the Prüfbereitschaft has open items. Nothing
* at all otherwise, without the right to read it, or when it cannot be loaded.
*/
export function ComplianceLine() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const { data, error, isError, isFetching, refetch } = useReadinessSummary(canRead);
if (!canRead) return null;
// Offline is normal on the dashboard; any other failure is said, quietly.
if (!data && isError && !isNetworkError(error)) {
return (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<p>Prüfbereitschaft nicht geladen.</p>
<Button type="button" size="touch" variant="ghost" disabled={isFetching} onClick={() => void refetch()}>
<RotateCw aria-hidden />
Erneut versuchen
</Button>
</div>
);
}
if (!data || data.openCount <= 0) return null;
const count = data.openCount === 1 ? "1 Punkt offen" : `${data.openCount} Punkte offen`;
return (
<Link
href="/compliance"
className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm outline-none hover:bg-destructive/10 focus-visible:ring-3 focus-visible:ring-ring/50"
>
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden />
<span className="flex-1">
<span className="font-semibold text-destructive">{count}</span> in der Prüfbereitschaft
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
}
@@ -94,6 +94,13 @@ describe("api-client", () => {
});
});
it("apiFetchBlob passes the request options on, e.g. cache: no-store", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("pdf"));
vi.stubGlobal("fetch", fetchMock);
await apiFetchBlob("/x", "f.pdf", { cache: "no-store" });
expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe("no-store");
});
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
+7 -3
View File
@@ -58,13 +58,17 @@ function filenameFromContentDisposition(header: string | null, fallback: string)
return match?.[1] ?? fallback;
}
export async function apiFetchBlob(path: string, fallbackFilename: string): Promise<BlobDownload> {
let response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
export async function apiFetchBlob(
path: string,
fallbackFilename: string,
init: RequestInit = {},
): Promise<BlobDownload> {
let response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
}
}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import {
acknowledge,
downloadReadinessReport,
fetchReadiness,
fetchReadinessSummary,
revokeAcknowledgement,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
describe("compliance api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads the report and the summary", async () => {
await fetchReadiness();
await fetchReadinessSummary();
expect(apiFetch).toHaveBeenNthCalledWith(1, "/compliance/readiness");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/compliance/readiness/summary");
});
it("acknowledges with one idempotency key per call", async () => {
const input = {
itemKey: "k",
subjectId: "global",
fingerprint: "f".repeat(64),
note: "Mit dem Amt abgesprochen.",
};
await acknowledge(input);
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/compliance/acknowledgements");
expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
expect((init?.headers as Record<string, string>)["Idempotency-Key"]).toMatch(/^[A-Za-z0-9_-]{16,128}$/);
});
it("revokes by id", async () => {
await revokeAcknowledgement("a/1");
expect(apiFetch).toHaveBeenCalledWith("/compliance/acknowledgements/a%2F1/revoke", { method: "POST" });
});
it("downloads the report as pruefbericht.pdf", async () => {
const blob = new Blob(["x"]);
vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "pruefbericht.pdf" });
await downloadReadinessReport();
expect(apiFetchBlob).toHaveBeenCalledWith("/compliance/readiness/report.pdf", "pruefbericht.pdf");
expect(downloadBlob).toHaveBeenCalledWith(blob, "pruefbericht.pdf");
});
});
@@ -0,0 +1,32 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { idempotencyHeaders, newIdempotencyKey } from "@/lib/offline/idempotency";
import type { AcknowledgeInput, ReadinessReport, ReadinessSummary } from "./types";
export function fetchReadiness(): Promise<ReadinessReport> {
return apiFetch<ReadinessReport>("/compliance/readiness");
}
export function fetchReadinessSummary(): Promise<ReadinessSummary> {
return apiFetch<ReadinessSummary>("/compliance/readiness/summary");
}
export function acknowledge(input: AcknowledgeInput): Promise<void> {
return apiFetch<void>("/compliance/acknowledgements", {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey: newIdempotencyKey() }),
body: JSON.stringify(input),
});
}
export function revokeAcknowledgement(id: string): Promise<void> {
return apiFetch<void>(`/compliance/acknowledgements/${encodeURIComponent(id)}/revoke`, {
method: "POST",
});
}
/** The Prüfbericht as PDF (authenticated download, never cached). */
export async function downloadReadinessReport(): Promise<void> {
const { blob, filename } = await apiFetchBlob("/compliance/readiness/report.pdf", "pruefbericht.pdf");
downloadBlob(blob, filename);
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { complianceErrorText, draftExists, readinessConflictText, restrictedZone, staleFingerprint } from "./errors";
const conflict = (body: unknown) => new ApiError(409, "x", body);
describe("compliance errors", () => {
it("reads DRAFT_EXISTS with existingId, RESTRICTED_ZONE with its restrictions and a stale fingerprint", () => {
expect(
draftExists(conflict({ code: "DRAFT_EXISTS", existingId: "n1", message: "Es gibt schon einen Entwurf." })),
).toEqual({ existingId: "n1", message: "Es gibt schon einen Entwurf." });
const restriction = { id: "r1", apiaryId: "a1", apiaryName: "Wiese", disease: "AFB", startsOn: "2026-10-01" };
expect(
restrictedZone(
conflict({ code: "RESTRICTED_ZONE", message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] }),
),
).toEqual({ message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] });
expect(staleFingerprint(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
"Der Punkt hat sich geändert. Bitte neu laden.",
);
});
it("ignores other errors", () => {
expect(draftExists(conflict({ code: "RESTRICTED_ZONE" }))).toBeNull();
expect(draftExists(new ApiError(400, "x", { code: "DRAFT_EXISTS", existingId: "n1" }))).toBeNull();
expect(draftExists(conflict({ code: "DRAFT_EXISTS", existingId: 5 }))).toBeNull();
expect(restrictedZone(conflict({ code: "DRAFT_EXISTS" }))).toBeNull();
expect(restrictedZone(new Error("boom"))).toBeNull();
expect(staleFingerprint(conflict({ message: "Die Quittung ist schon zurückgenommen." }))).toBeNull();
expect(staleFingerprint(null)).toBeNull();
});
it("drops malformed restrictions instead of throwing", () => {
expect(
restrictedZone(
conflict({ code: "RESTRICTED_ZONE", message: "m", restrictionIds: ["r1", 3], restrictions: [{ id: 1 }, null] }),
),
).toEqual({ message: "m", restrictionIds: ["r1"], restrictions: [] });
});
it("builds on saveErrorText for the text line", () => {
expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
});
it("gives each conflict code its own German text", () => {
const texts = ["READINESS_FINGERPRINT_STALE", "READINESS_ITEM_NOT_OPEN", "READINESS_ACK_REVOKED"].map((code) =>
readinessConflictText(conflict({ code, message: "English or whatever" })),
);
expect(texts.every((text) => typeof text === "string" && text.length > 0)).toBe(true);
expect(new Set(texts).size).toBe(3);
expect(texts[0]).toContain("geändert");
expect(texts[1]).toContain("nicht mehr offen");
expect(texts[2]).toContain("zurückgenommen");
});
it("prefers the code over the message and falls back to the message text", () => {
expect(
readinessConflictText(
conflict({ code: "READINESS_ITEM_NOT_OPEN", message: "Der Punkt hat sich geändert. Bitte neu laden." }),
),
).toContain("nicht mehr offen");
expect(readinessConflictText(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
"Der Punkt hat sich geändert. Bitte neu laden.",
);
expect(readinessConflictText(conflict({ code: "SOMETHING_ELSE", message: "x" }))).toBeNull();
expect(readinessConflictText(new ApiError(500, "x", { code: "READINESS_ACK_REVOKED" }))).toBeNull();
expect(readinessConflictText(new Error("x"))).toBeNull();
});
});
@@ -0,0 +1,97 @@
import { ApiError } from "@/lib/auth/api-client";
import { saveErrorText } from "@/lib/honey-batches/errors";
/** The server's wording for an acknowledgement whose item changed meanwhile. */
const STALE_FINGERPRINT_MESSAGE = "Der Punkt hat sich geändert. Bitte neu laden.";
export interface DraftExists {
existingId: string;
message: string;
}
export interface RestrictionInfo {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
startsOn: string;
}
export interface RestrictedZone {
message: string;
restrictionIds: string[];
restrictions: RestrictionInfo[];
}
function conflictBody(error: unknown): Record<string, unknown> | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
return typeof error.body === "object" && error.body !== null
? (error.body as Record<string, unknown>)
: null;
}
function textOf(value: unknown): string {
return typeof value === "string" ? value : "";
}
function isRestrictionInfo(value: unknown): value is RestrictionInfo {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return ["id", "apiaryId", "apiaryName", "disease", "startsOn"].every(
(key) => typeof row[key] === "string",
);
}
/** A 409 `DRAFT_EXISTS`: the id of the draft that is already open. */
export function draftExists(error: unknown): DraftExists | null {
const body = conflictBody(error);
if (body?.code !== "DRAFT_EXISTS" || typeof body.existingId !== "string") return null;
return { existingId: body.existingId, message: textOf(body.message) };
}
/** A 409 `RESTRICTED_ZONE`: the restrictions that block the move or the change. */
export function restrictedZone(error: unknown): RestrictedZone | null {
const body = conflictBody(error);
if (body?.code !== "RESTRICTED_ZONE") return null;
const ids = Array.isArray(body.restrictionIds) ? body.restrictionIds : [];
const rows = Array.isArray(body.restrictions) ? body.restrictions : [];
return {
message: textOf(body.message),
restrictionIds: ids.filter((id): id is string => typeof id === "string"),
restrictions: rows.filter(isRestrictionInfo),
};
}
/** Codes of a 409 on acknowledging or revoking, each with its own German text. */
const READINESS_CONFLICT_TEXT: Readonly<Record<string, string>> = {
READINESS_FINGERPRINT_STALE:
"Der Punkt hat sich inzwischen geändert. Die Liste wird neu geladen, bitte prüf ihn noch einmal.",
READINESS_ITEM_NOT_OPEN: "Dieser Punkt ist nicht mehr offen. Die Liste wird neu geladen.",
READINESS_ACK_REVOKED: "Diese Quittung ist schon zurückgenommen. Die Liste wird neu geladen.",
};
/**
* A 409 on an acknowledgement whose item changed since the page was loaded
* (stale fingerprint): the server text. Fallback for a server that sends no
* code; `readinessConflictText` prefers the code.
*/
export function staleFingerprint(error: unknown): string | null {
const body = conflictBody(error);
return body && textOf(body.message) === STALE_FINGERPRINT_MESSAGE ? STALE_FINGERPRINT_MESSAGE : null;
}
/**
* The German text for a 409 of the acknowledgement endpoints: by `body.code`
* (stale fingerprint, item not open, already revoked); the message match is
* only the fallback. `null` for every other error.
*/
export function readinessConflictText(error: unknown): string | null {
const body = conflictBody(error);
const code = typeof body?.code === "string" ? body.code : "";
return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
}
/** One line for every other failure: the shared reader, no second error logic. */
export function complianceErrorText(error: unknown, fallback: string): string {
return saveErrorText(error, fallback);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { ROUTE_AVAILABLE, fixLinkFor } from "./fix-links";
const STAMMDATEN = "/settings/automation?tab=social-media";
describe("fixLinkFor", () => {
it.each([
["medicine-lot-gaps", { type: "lot", id: "l1" }, "/medicines/lots/l1"],
["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }, "/colonies/c1"],
["label-gaps", { type: "template", id: "x1" }, "/settings/label-templates/x1"],
["harvest-withdrawal", { type: "harvest", id: "h1" }, "/harvests/h1"],
["registration", { type: "notice", id: "n1" }, "/compliance/notices/n1"],
["registration", { type: "AUTHORITY_NOTICE", id: "n2" }, "/compliance/notices/n2"],
["restriction-active", { type: "apiary", id: "a1" }, "/compliance/restrictions"],
["restriction-active", { type: "APIARY", id: "a1" }, "/compliance/restrictions"],
["restriction-active", { type: "restriction", id: "r1" }, "/compliance/restrictions"],
["herd-change", { type: "APIARY", id: "a1" }, "/compliance/notices"],
["vet-office", { type: "COMPLIANCE_SETTINGS", id: "global" }, "/compliance/settings"],
])("maps %s with %j to %s", (itemKey, subject, href) => {
expect(fixLinkFor(itemKey, subject).href).toBe(href);
});
it.each([
["holder-data", STAMMDATEN],
["food-business-data", STAMMDATEN],
["vet-office", "/compliance/settings"],
["registration", "/compliance/notices"],
["herd-change", "/compliance/notices"],
["tsk-report", "/compliance/notices"],
["restriction-active", "/compliance/restrictions"],
["medicine-withdrawal-missing", "/medicines"],
])("maps the global item %s to %s", (itemKey, href) => {
expect(fixLinkFor(itemKey, null).href).toBe(href);
});
it("treats the site settings subject like a global item", () => {
expect(fixLinkFor("holder-data", { type: "SITE_SETTINGS", id: "global" }).href).toBe(STAMMDATEN);
});
it("encodes ids", () => {
expect(fixLinkFor("x", { type: "lot", id: "a/b" }).href).toBe("/medicines/lots/a%2Fb");
});
it("falls back to the compliance page and always says Beheben", () => {
expect(fixLinkFor("unknown-rule", null)).toEqual({ href: "/compliance", label: "Beheben", available: false });
expect(fixLinkFor("holder-data", null).label).toBe("Beheben");
});
it("links to the colony page for a treatment, no parameter nothing reads", () => {
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }).href).toBe("/colonies/c1");
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false);
});
it.each([
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("hides %s until its target page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(false);
});
it.each([
["holder-data", null],
["medicine-lot-gaps", { type: "lot", id: "l1" }],
["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }],
["label-gaps", { type: "template", id: "x1" }],
["harvest-withdrawal", { type: "harvest", id: "h1" }],
["medicine-withdrawal-missing", null],
])("shows %s, its page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(true);
});
it("is switched on per route in one set", () => {
expect(ROUTE_AVAILABLE.has("/compliance/notices")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/restrictions")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/settings")).toBe(false);
});
});
@@ -0,0 +1,96 @@
const FIX_LABEL = "Beheben";
/**
* The pages that exist. A „Beheben“ link to any other route is hidden, so
* nobody lands on a 404. Tasks 18 and 19 add their routes here to switch the
* links on: `/compliance/notices`, `/compliance/notices/[id]`,
* `/compliance/restrictions` and `/compliance/settings`.
*/
export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([
"/colonies/[id]",
"/harvests/[id]",
"/medicines",
"/medicines/lots/[id]",
"/settings/automation",
"/settings/label-templates/[id]",
]);
/** The tab "Stammdaten & Web" under Verwaltung > Automatisierung. */
const MASTER_DATA = "/settings/automation?tab=social-media";
interface Target {
/** The route pattern, as a key of ROUTE_AVAILABLE. */
route: string;
href: string;
}
/** A rule without a record of its own, or the fallback when nothing else fits. */
const TARGET_BY_ITEM_KEY: Readonly<Record<string, Target>> = {
"holder-data": { route: "/settings/automation", href: MASTER_DATA },
"food-business-data": { route: "/settings/automation", href: MASTER_DATA },
"vet-office": { route: "/compliance/settings", href: "/compliance/settings" },
registration: { route: "/compliance/notices", href: "/compliance/notices" },
"herd-change": { route: "/compliance/notices", href: "/compliance/notices" },
"tsk-report": { route: "/compliance/notices", href: "/compliance/notices" },
"restriction-active": { route: "/compliance/restrictions", href: "/compliance/restrictions" },
"medicine-withdrawal-missing": { route: "/medicines", href: "/medicines" },
};
/** This page itself: never a fix target. */
const COMPLIANCE_HOME: Target = { route: "/compliance", href: "/compliance" };
export interface FixSubject {
type: string;
id: string;
parentId?: string;
}
export interface FixLink {
href: string;
label: string;
/** False while the target page does not exist yet: show no link. */
available: boolean;
}
function targetForSubject(itemKey: string, subject: FixSubject): Target | null {
const id = encodeURIComponent(subject.id);
switch (subject.type.toLowerCase()) {
case "lot":
return { route: "/medicines/lots/[id]", href: `/medicines/lots/${id}` };
case "treatment":
// The treatments are listed (and edited) on the colony page.
return subject.parentId
? { route: "/colonies/[id]", href: `/colonies/${encodeURIComponent(subject.parentId)}` }
: null;
case "template":
return { route: "/settings/label-templates/[id]", href: `/settings/label-templates/${id}` };
case "harvest":
return { route: "/harvests/[id]", href: `/harvests/${id}` };
case "notice":
case "authority_notice":
return { route: "/compliance/notices/[id]", href: `/compliance/notices/${id}` };
case "apiary":
// A herd change is settled by a notice; every other apiary item is a restriction.
return itemKey === "herd-change"
? TARGET_BY_ITEM_KEY["herd-change"]
: TARGET_BY_ITEM_KEY["restriction-active"];
case "restriction":
return TARGET_BY_ITEM_KEY["restriction-active"];
case "compliance_settings":
return TARGET_BY_ITEM_KEY["vet-office"];
default:
return null;
}
}
/**
* The ONE place that maps a readiness subject to the page that fixes it.
* Without a subject of its own (or for the settings subjects) the rule decides.
*/
export function fixLinkFor(itemKey: string, subject: FixSubject | null): FixLink {
const target =
(subject ? targetForSubject(itemKey, subject) : null) ??
TARGET_BY_ITEM_KEY[itemKey] ??
COMPLIANCE_HOME;
return { href: target.href, label: FIX_LABEL, available: ROUTE_AVAILABLE.has(target.route) };
}
@@ -0,0 +1,35 @@
import type { ReadinessItem, ReadinessReport } from "./types";
/** Test data for the readiness report; override only what a test is about. */
export function itemFixture(overrides: Partial<ReadinessItem> = {}): ReadinessItem {
return {
key: "holder-data",
area: "STAMMDATEN",
severity: "PFLICHT",
subjectId: "global",
title: "Angaben zum Bienenhalter fehlen",
detail: "Es fehlen: Name, Anschrift.",
legalBasis: null,
acknowledgeable: false,
fingerprint: "f".repeat(64),
subjects: [],
more: 0,
state: "OPEN",
acknowledgement: null,
...overrides,
};
}
export function reportFixture(
items: ReadinessItem[],
overrides: Partial<ReadinessReport> = {},
): ReadinessReport {
const openCount = items.filter((item) => item.state === "OPEN").length;
return {
status: openCount > 0 ? "OPEN" : "READY",
generatedAt: "2026-10-15T08:00:00.000Z",
openCount,
items,
...overrides,
};
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { AREA_LABEL, AREA_ORDER, SEVERITY_LABEL, STATE_LABEL, itemTone } from "./labels";
describe("compliance labels", () => {
it("maps the severity to its German word", () => {
expect(SEVERITY_LABEL).toEqual({ PFLICHT: "Pflicht", PRUEFEN: "Prüfen" });
});
it("names the five areas in a fixed order", () => {
expect(AREA_ORDER).toEqual(["STAMMDATEN", "BEHOERDEN", "TIERARZNEIMITTEL", "ETIKETTEN", "ERNTE"]);
expect(AREA_ORDER.every((area) => AREA_LABEL[area].length > 0)).toBe(true);
});
it("shows every open item red with symbol and word, whatever its severity", () => {
expect(itemTone("OPEN")).toEqual({ tone: "destructive", word: "Offen" });
expect(STATE_LABEL).toEqual({ OPEN: "Offen", DONE: "Erledigt", ACKNOWLEDGED: "Quittiert" });
expect(itemTone("DONE").tone).not.toBe("destructive");
expect(itemTone("ACKNOWLEDGED").tone).not.toBe("destructive");
});
});
@@ -0,0 +1,44 @@
import type { ComponentProps } from "react";
import type { Badge } from "@/components/ui/badge";
import type { ReadinessArea, ReadinessItemState, ReadinessSeverity } from "./types";
/** The severity is always a word next to the title, never only a colour. */
export const SEVERITY_LABEL: Record<ReadinessSeverity, string> = {
PFLICHT: "Pflicht",
PRUEFEN: "Prüfen",
};
export const AREA_ORDER: readonly ReadinessArea[] = [
"STAMMDATEN",
"BEHOERDEN",
"TIERARZNEIMITTEL",
"ETIKETTEN",
"ERNTE",
];
export const AREA_LABEL: Record<ReadinessArea, string> = {
STAMMDATEN: "Stammdaten",
BEHOERDEN: "Behörden",
TIERARZNEIMITTEL: "Tierarzneimittel",
ETIKETTEN: "Etiketten",
ERNTE: "Ernte",
};
export const STATE_LABEL: Record<ReadinessItemState, string> = {
OPEN: "Offen",
DONE: "Erledigt",
ACKNOWLEDGED: "Quittiert",
};
export interface ItemTone {
tone: NonNullable<ComponentProps<typeof Badge>["variant"]>;
word: string;
}
/** Every open item is red (token destructive), whatever its severity. */
export function itemTone(state: ReadinessItemState): ItemTone {
const word = STATE_LABEL[state];
if (state === "OPEN") return { tone: "destructive", word };
if (state === "ACKNOWLEDGED") return { tone: "outline", word };
return { tone: "secondary", word };
}
@@ -0,0 +1,63 @@
// Mirrors the backend's readiness report (ApiCore-Backend/src/compliance/readiness/readiness.types.ts).
export type ReadinessSeverity = "PFLICHT" | "PRUEFEN";
export type ReadinessArea =
| "STAMMDATEN"
| "BEHOERDEN"
| "TIERARZNEIMITTEL"
| "ETIKETTEN"
| "ERNTE";
export type ReadinessItemState = "OPEN" | "DONE" | "ACKNOWLEDGED";
export type ReadinessStatus = "READY" | "OPEN";
export interface ReadinessSubject {
type: string;
id: string;
label: string;
missing?: string[];
/** e.g. the colony of a treatment (link target). */
parentId?: string;
}
export interface ReadinessAcknowledgement {
id: string;
note: string;
userName: string;
createdAt: string;
}
export interface ReadinessItem {
key: string;
area: ReadinessArea;
severity: ReadinessSeverity;
subjectId: string;
title: string;
detail: string;
legalBasis: string | null;
acknowledgeable: boolean;
fingerprint: string;
subjects: ReadinessSubject[];
more: number;
state: ReadinessItemState;
acknowledgement: ReadinessAcknowledgement | null;
}
export interface ReadinessReport {
status: ReadinessStatus;
generatedAt: string;
openCount: number;
items: ReadinessItem[];
}
export interface ReadinessSummary {
status: ReadinessStatus;
openCount: number;
firstItemKey: string | null;
}
export interface AcknowledgeInput {
itemKey: string;
subjectId: string;
fingerprint: string;
note: string;
}
@@ -0,0 +1,22 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchReadiness, fetchReadinessSummary } from "./api";
/** Every compliance query starts with this key, so one invalidation reloads all. */
export const complianceKey = ["compliance"] as const;
export const readinessKey = [...complianceKey, "readiness"] as const;
export const readinessSummaryKey = [...complianceKey, "readiness", "summary"] as const;
export function useReadiness() {
return useQuery({ queryKey: readinessKey, queryFn: fetchReadiness });
}
export function useReadinessSummary(enabled: boolean) {
return useQuery({
queryKey: readinessSummaryKey,
queryFn: fetchReadinessSummary,
enabled,
retry: false,
});
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { DOCUMENT_ENTITY_TYPES } from "./types";
describe("DOCUMENT_ENTITY_TYPES", () => {
it("knows the three compliance kinds besides the existing ones", () => {
expect(DOCUMENT_ENTITY_TYPES).toEqual([
"APIARY",
"COLONY",
"QUEEN",
"INSPECTION",
"HONEY_BATCH",
"MEDICINE_LOT",
"AUTHORITY_NOTICE",
"APIARY_RESTRICTION",
]);
});
});
+12 -1
View File
@@ -1,4 +1,15 @@
export type DocumentEntityType = "APIARY" | "COLONY" | "QUEEN" | "INSPECTION" | "HONEY_BATCH";
export const DOCUMENT_ENTITY_TYPES = [
"APIARY",
"COLONY",
"QUEEN",
"INSPECTION",
"HONEY_BATCH",
"MEDICINE_LOT",
"AUTHORITY_NOTICE",
"APIARY_RESTRICTION",
] as const;
export type DocumentEntityType = (typeof DOCUMENT_ENTITY_TYPES)[number];
export interface DocumentSummary {
id: string;
@@ -1,5 +1,4 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { CreateLabelTemplateInput, LabelTemplate, UpdateLabelTemplateInput } from "./types";
export function fetchLabelTemplates(): Promise<LabelTemplate[]> {
@@ -27,15 +26,32 @@ export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
return apiFetch<LabelTemplate>(`/label-templates/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface PrintLabelsOptions {
/** 1 = top left of the sheet; omitted prints from the start. */
startPosition?: number;
/** A test print stamped „MUSTER – nicht verkaufsfähig“; always allowed. */
proof?: boolean;
}
/**
* The label PDF. A 409 `LABEL_PARTICULARS_MISSING` throws an `ApiError` whose
* `body.gaps` lists what is missing (`labelParticularsMissing` reads it).
*/
export async function printLabels(
templateId: string,
bottlingBatchId: string,
startPosition?: number,
): Promise<void> {
const query = startPosition && startPosition > 1 ? `?startPosition=${startPosition}` : "";
const { blob, filename } = await apiFetchBlob(
options: PrintLabelsOptions = {},
): Promise<Blob> {
const params = new URLSearchParams();
if (options.startPosition && options.startPosition > 1) {
params.set("startPosition", String(options.startPosition));
}
if (options.proof) params.set("proof", "true");
const query = params.size > 0 ? `?${params.toString()}` : "";
const { blob } = await apiFetchBlob(
`/label-templates/${encodeURIComponent(templateId)}/print/${encodeURIComponent(bottlingBatchId)}${query}`,
"etiketten.pdf",
{ cache: "no-store" },
);
downloadBlob(blob, filename);
return blob;
}
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { apiFetch, ApiError } from "@/lib/auth/api-client";
import {
checkDraftTemplate,
checkTemplateForBottling,
labelCheckErrorText,
labelParticularsMissing,
useDebouncedLabelCheck,
type LabelCheckResult,
} from "./check";
import type { LabelElement } from "./types";
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
const RESULT: LabelCheckResult = {
printable: true,
gaps: [],
warnings: [],
sample: true,
requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: null },
};
const TEXT: LabelElement = {
id: "t1",
type: "text",
x: 1,
y: 2,
width: 40,
height: 8,
text: "Honig",
placeholderKey: null,
fontSize: 8,
fontFamily: "sans",
bold: false,
italic: false,
color: "#000000",
align: "left",
};
const QR: LabelElement = { id: "q1", type: "qrCode", x: 0, y: 0, size: 10 };
const LINE: LabelElement = {
id: "l1",
type: "line",
x: 0,
y: 0,
length: 10,
thickness: 1,
color: "#000000",
orientation: "horizontal",
};
const DRAFT = { elements: [TEXT, QR, LINE], widthMm: 90, heightMm: 35, minXHeightMm: 1.2 };
function sentBody(): { elements: { id: string }[]; [key: string]: unknown } {
const init = vi.mocked(apiFetch).mock.calls.at(-1)![1] as RequestInit;
return JSON.parse(String(init.body));
}
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(RESULT);
});
describe("checkDraftTemplate", () => {
it("posts the text elements only", async () => {
await checkDraftTemplate(DRAFT);
expect(apiFetch).toHaveBeenCalledWith(
"/label-templates/check",
expect.objectContaining({ method: "POST" }),
);
const body = sentBody();
expect(body.elements.map((element) => element.id)).toEqual(["t1"]);
expect(body).toMatchObject({ widthMm: 90, heightMm: 35, minXHeightMm: 1.2 });
expect(body).not.toHaveProperty("bottlingBatchId");
});
it("sends the bottling batch when one is given", async () => {
await checkDraftTemplate({ ...DRAFT, bottlingBatchId: "b-1" });
expect(sentBody()).toMatchObject({ bottlingBatchId: "b-1" });
});
});
describe("checkTemplateForBottling", () => {
it("reads the check of a saved template for one bottling", async () => {
await checkTemplateForBottling("tpl 1", "b-1");
expect(apiFetch).toHaveBeenCalledWith("/label-templates/tpl%201/check/b-1", {
cache: "no-store",
});
});
});
describe("labelParticularsMissing", () => {
const gaps = [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "d", elementIds: [] }];
it("reads the gaps of a 409 LABEL_PARTICULARS_MISSING", () => {
const error = new ApiError(409, "Pflichtangaben fehlen.", {
code: "LABEL_PARTICULARS_MISSING",
message: "Pflichtangaben fehlen.",
gaps,
});
expect(labelParticularsMissing(error)).toEqual({ message: "Pflichtangaben fehlen.", gaps });
});
it("ignores other errors and malformed bodies", () => {
expect(labelParticularsMissing(new Error("x"))).toBeNull();
expect(labelParticularsMissing(new ApiError(409, "x", { code: "OTHER" }))).toBeNull();
expect(
labelParticularsMissing(new ApiError(500, "x", { code: "LABEL_PARTICULARS_MISSING", gaps })),
).toBeNull();
expect(
labelParticularsMissing(
new ApiError(409, "x", { code: "LABEL_PARTICULARS_MISSING", gaps: "no" }),
),
).toBeNull();
});
});
describe("labelCheckErrorText", () => {
it("explains a missing permission", () => {
expect(labelCheckErrorText(new ApiError(403, "Forbidden"), "f")).toMatch(/Berechtigung/);
});
it("gives the invalid compliance settings a clear German message", () => {
const error = new ApiError(409, "Compliance-Einstellungen fehlen oder sind ungültig.", {
code: "COMPLIANCE_SETTINGS_INVALID",
message: "Compliance-Einstellungen fehlen oder sind ungültig.",
});
expect(labelCheckErrorText(error, "f")).toMatch(/Compliance-Einstellungen/);
});
it("falls back for unknown errors", () => {
expect(labelCheckErrorText(new Error("boom"), "Fallback")).toBe("Fallback");
});
});
describe("useDebouncedLabelCheck", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("waits 500 ms after the last change before it asks the server", async () => {
const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
expect(result.current.pending).toBe(true);
await act(async () => {
vi.advanceTimersByTime(300);
});
rerender({ draft: { ...DRAFT, widthMm: 91 } });
await act(async () => {
vi.advanceTimersByTime(499);
});
expect(apiFetch).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(sentBody()).toMatchObject({ widthMm: 91 });
expect(result.current.result).toEqual(RESULT);
expect(result.current.pending).toBe(false);
expect(result.current.error).toBeNull();
});
it("does not ask again for an equal draft", async () => {
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
rerender({ draft: { ...DRAFT, elements: [...DRAFT.elements] } });
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("ignores changes to elements that are not text", async () => {
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
rerender({ draft: { ...DRAFT, elements: [TEXT, { ...QR, x: 9 }, LINE] } });
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("reports a failed check as a German error", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
const { result } = renderHook(() => useDebouncedLabelCheck(DRAFT));
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(result.current.error).toMatch(/Berechtigung/);
expect(result.current.pending).toBe(false);
});
it("asks without caching and with an abort signal, and aborts a request that a newer draft replaces", async () => {
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
expect(init.cache).toBe("no-store");
expect(init.signal?.aborted).toBe(false);
rerender({ draft: { ...DRAFT, widthMm: 95 } });
expect(init.signal?.aborted).toBe(true);
});
it("aborts the request when the component goes away", async () => {
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
const { unmount } = renderHook(() => useDebouncedLabelCheck(DRAFT));
await act(async () => {
vi.advanceTimersByTime(500);
});
const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
unmount();
expect(init.signal?.aborted).toBe(true);
});
it("marks the old result stale when the draft becomes invalid", async () => {
const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(result.current.result).toEqual(RESULT);
expect(result.current.stale).toBe(false);
rerender({ draft: { ...DRAFT, minXHeightMm: Number.NaN } });
expect(result.current.stale).toBe(true);
expect(result.current.pending).toBe(false);
rerender({ draft: DRAFT });
expect(result.current.stale).toBe(false);
});
it("does not ask while the minimum x-height is out of range", async () => {
renderHook(() => useDebouncedLabelCheck({ ...DRAFT, minXHeightMm: 9 }));
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(apiFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,237 @@
import { useEffect, useState } from "react";
import { apiFetch, ApiError } from "@/lib/auth/api-client";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { saveErrorText } from "@/lib/honey-batches/errors";
import type { LabelElement, LabelTextElement } from "./types";
/**
* The label check of the server. It holds every legal value (minimum x-height,
* digit heights), the frontend only shows what comes back and never decides.
*
* The answer of a check against a bottling contains the food business address:
* it is never put into a persisted query cache or the service worker cache
* (`/label-templates/:id/check/…` is in `NEVER_CACHED_API_PATHS`).
*/
export type LabelFindingCode =
| "LEGAL_NAME"
| "NET_QUANTITY"
| "BEST_BEFORE"
| "LOT"
| "ORIGIN"
| "FOOD_BUSINESS"
| "LOT_PREFIX"
| "X_HEIGHT"
| "NET_QUANTITY_HEIGHT"
| "TEXT_CLIPPED";
/** The six particulars that block printing, in the order the panel lists them. */
export const PARTICULAR_CODES = [
"LEGAL_NAME",
"NET_QUANTITY",
"BEST_BEFORE",
"LOT",
"ORIGIN",
"FOOD_BUSINESS",
] as const satisfies readonly LabelFindingCode[];
export interface LabelFinding {
code: LabelFindingCode;
title: string;
detail: string;
/** Text elements that carry (or would carry) the finding. */
elementIds: string[];
}
export interface LabelCheckResult {
/** No gap: the label may be printed. Warnings never block. */
printable: boolean;
gaps: LabelFinding[];
warnings: LabelFinding[];
/** Checked with example values (no bottling chosen). */
sample: boolean;
/** The legal values the check used. */
requirements: {
minXHeightMm: number;
netQuantityMinCapHeightMm: number | null;
};
}
export interface DraftInput {
elements: LabelElement[];
widthMm: number;
heightMm: number;
minXHeightMm: number;
bottlingBatchId?: string;
}
interface DraftBody {
elements: LabelTextElement[];
widthMm: number;
heightMm: number;
minXHeightMm: number;
bottlingBatchId?: string;
}
export const CHECK_DEBOUNCE_MS = 500;
/**
* Mirrors the bounds of `CheckLabelTemplateDto` / `UpdateLabelTemplateDto`. A technical
* range that keeps invalid input from being sent, not a legal value: the minimum
* that applies comes with every check result.
*/
export const MIN_X_HEIGHT_RANGE_MM = { min: 0.5, max: 5 } as const;
const SHEET_RANGE_MM = { min: 10, max: 1000 } as const;
const MAX_CHECK_ELEMENTS = 100;
const CHECK_FALLBACK = "Die Prüfung ist fehlgeschlagen.";
function isTextElement(element: LabelElement): element is LabelTextElement {
return element.type === "text";
}
function isWithin(value: number, range: { min: number; max: number }): boolean {
return Number.isFinite(value) && value >= range.min && value <= range.max;
}
function toBody(input: DraftInput): DraftBody {
return {
elements: input.elements.filter(isTextElement),
widthMm: input.widthMm,
heightMm: input.heightMm,
minXHeightMm: input.minXHeightMm,
...(input.bottlingBatchId ? { bottlingBatchId: input.bottlingBatchId } : {}),
};
}
function isCheckable(body: DraftBody): boolean {
return (
isWithin(body.minXHeightMm, MIN_X_HEIGHT_RANGE_MM) &&
isWithin(body.widthMm, SHEET_RANGE_MM) &&
isWithin(body.heightMm, SHEET_RANGE_MM) &&
body.elements.length <= MAX_CHECK_ELEMENTS
);
}
function postDraft(body: DraftBody, signal?: AbortSignal): Promise<LabelCheckResult> {
return apiFetch<LabelCheckResult>("/label-templates/check", {
method: "POST",
body: JSON.stringify(body),
cache: "no-store",
signal,
});
}
/** Checks an unsaved draft; only its text elements are sent. */
export function checkDraftTemplate(
input: DraftInput,
signal?: AbortSignal,
): Promise<LabelCheckResult> {
return postDraft(toBody(input), signal);
}
/** Checks a saved template against one bottling (real values, no sample). */
export function checkTemplateForBottling(
templateId: string,
bottlingBatchId: string,
): Promise<LabelCheckResult> {
return apiFetch<LabelCheckResult>(
`/label-templates/${encodeURIComponent(templateId)}/check/${encodeURIComponent(bottlingBatchId)}`,
{ cache: "no-store" },
);
}
export interface LabelParticularsMissing {
message: string;
gaps: LabelFinding[];
}
function isFinding(value: unknown): value is LabelFinding {
if (typeof value !== "object" || value === null) return false;
const finding = value as Partial<LabelFinding>;
return typeof finding.code === "string" && typeof finding.title === "string";
}
/** A 409 `LABEL_PARTICULARS_MISSING` with its gaps, otherwise `null`. */
export function labelParticularsMissing(error: unknown): LabelParticularsMissing | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body as { code?: unknown; message?: unknown; gaps?: unknown } | null;
if (body?.code !== "LABEL_PARTICULARS_MISSING") return null;
if (!Array.isArray(body.gaps) || !body.gaps.every(isFinding)) return null;
return {
message: typeof body.message === "string" ? body.message : error.message,
gaps: body.gaps,
};
}
function isComplianceSettingsInvalid(error: unknown): boolean {
if (!(error instanceof ApiError) || error.status !== 409) return false;
return (error.body as { code?: unknown } | null)?.code === "COMPLIANCE_SETTINGS_INVALID";
}
/** The ONE reader of a failed check or print: German text for 403, offline and invalid settings. */
export function labelCheckErrorText(error: unknown, fallback: string): string {
if (isNetworkError(error)) return "Das geht nur mit Verbindung.";
if (error instanceof ApiError && error.status === 403) {
return "Dir fehlt die Berechtigung für Etiketten.";
}
if (isComplianceSettingsInvalid(error)) {
return "Die Compliance-Einstellungen fehlen oder sind ungültig. Bitte melde das der Administration.";
}
return saveErrorText(error, fallback);
}
interface CheckState {
/** The serialized draft this state answers. */
key: string | null;
result: LabelCheckResult | null;
error: string | null;
}
/**
* Checks the draft 500 ms after the last change. Only the text elements count:
* moving a QR code or a line does not trigger a request. While a value is out
* of range (the person is still typing) nothing is asked and the last answer stays.
*/
export function useDebouncedLabelCheck(
draft: DraftInput,
delayMs: number = CHECK_DEBOUNCE_MS,
): {
result: LabelCheckResult | null;
pending: boolean;
error: string | null;
/** The draft is invalid right now, so `result` answers an older draft. */
stale: boolean;
} {
const body = toBody(draft);
const payload = isCheckable(body) ? JSON.stringify(body) : null;
const [state, setState] = useState<CheckState>({ key: null, result: null, error: null });
useEffect(() => {
if (payload === null) return;
let cancelled = false;
const controller = new AbortController();
const timer = setTimeout(() => {
postDraft(JSON.parse(payload) as DraftBody, controller.signal).then(
(result) => {
if (!cancelled) setState({ key: payload, result, error: null });
},
(cause: unknown) => {
if (!cancelled) {
setState({ key: payload, result: null, error: labelCheckErrorText(cause, CHECK_FALLBACK) });
}
},
);
}, delayMs);
return () => {
cancelled = true;
controller.abort();
clearTimeout(timer);
};
}, [payload, delayMs]);
return {
result: state.result,
pending: payload !== null && state.key !== payload,
error: state.error,
stale: payload === null && state.result !== null,
};
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildComplianceBlock } from "./compliance-block";
import type { LabelCheckResult } from "./check";
const TEMPLATE = { widthMm: 90, heightMm: 60 };
const NEW_KEYS = [
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine",
] as const;
// Helvetica metrics the backend measures with (thousandths of the font size).
const X_HEIGHT = 523;
const CAP_HEIGHT = 718;
const MM_PER_PT = 25.4 / 72;
function requirements(
minXHeightMm: number,
netQuantityMinCapHeightMm: number | null,
): LabelCheckResult["requirements"] {
return { minXHeightMm, netQuantityMinCapHeightMm };
}
describe("buildComplianceBlock", () => {
it("inserts one text element per new placeholder, sized to meet the required x-height", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
for (const key of NEW_KEYS) {
const matching = block.filter((element) => element.placeholderKey === key);
expect(matching).toHaveLength(1);
const xHeightMm = (matching[0].fontSize * X_HEIGHT * MM_PER_PT) / 1000;
expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
}
expect(block.every((element) => element.type === "text")).toBe(true);
expect(new Set(block.map((element) => element.id)).size).toBe(block.length);
});
it("scales the size with the required x-height instead of using a fixed number", () => {
const small = buildComplianceBlock(TEMPLATE, requirements(0.9, null));
const large = buildComplianceBlock(TEMPLATE, requirements(2.4, null));
const legalSize = (block: ReturnType<typeof buildComplianceBlock>) =>
block.find((element) => element.placeholderKey === "legalName")!.fontSize;
expect(legalSize(large)).toBeGreaterThan(legalSize(small));
});
it("uses the digit height requirement for the net quantity element when present", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
const net = block.find((element) => element.placeholderKey === "jarSize");
expect(net).toBeDefined();
const capHeightMm = (net!.fontSize * CAP_HEIGHT * MM_PER_PT) / 1000;
expect(capHeightMm).toBeGreaterThanOrEqual(3 - 1e-9);
});
it("sizes the net quantity by the x-height when no digit rule applies", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
const net = block.find((element) => element.placeholderKey === "jarSize");
const xHeightMm = (net!.fontSize * X_HEIGHT * MM_PER_PT) / 1000;
expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
});
it("keeps every element inside the label width", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
for (const element of block) {
expect(element.x).toBeGreaterThanOrEqual(0);
expect(element.x + element.width).toBeLessThanOrEqual(TEMPLATE.widthMm);
}
});
it("stacks the elements without overlap", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
for (let index = 1; index < block.length; index += 1) {
expect(block[index].y).toBeGreaterThanOrEqual(block[index - 1].y + block[index - 1].height);
}
});
});
@@ -0,0 +1,83 @@
import type { LabelCheckResult } from "./check";
import type { LabelTextElement, PlaceholderKey } from "./types";
// Metrics of the built-in PDF font (Helvetica, thousandths of the font size),
// the same ones the server measures with. They describe the font, not the law:
// the legal minimums arrive with every check result.
const X_HEIGHT_RATIO = 0.523;
const CAP_HEIGHT_RATIO = 0.718;
const MM_PER_PT = 25.4 / 72;
const SIZE_STEP_PT = 0.5;
const LINE_HEIGHT_FACTOR = 1.25;
const EDGE_MM = 2;
const ROW_GAP_MM = 0.5;
const EPSILON = 1e-9;
interface BlockRow {
key: PlaceholderKey;
/** Which height decides the size. */
basis: "xHeight" | "netQuantity";
lines: number;
}
// Legal name and net quantity first, then the dates and the origin, the
// food business last: the order a buyer reads them on a jar.
const BLOCK_ROWS: readonly BlockRow[] = [
{ key: "legalName", basis: "xHeight", lines: 1 },
{ key: "jarSize", basis: "netQuantity", lines: 1 },
{ key: "bestBeforeLine", basis: "xHeight", lines: 1 },
{ key: "lotLine", basis: "xHeight", lines: 1 },
{ key: "originCountry", basis: "xHeight", lines: 1 },
{ key: "foodBusinessName", basis: "xHeight", lines: 1 },
{ key: "foodBusinessAddress", basis: "xHeight", lines: 2 },
];
/** Smallest font size (in half points) whose glyph height reaches `heightMm`. */
function fontSizeFor(heightMm: number, ratio: number): number {
const points = heightMm / MM_PER_PT / ratio;
return Math.ceil(points / SIZE_STEP_PT - EPSILON) * SIZE_STEP_PT;
}
function sizeOf(row: BlockRow, requirements: LabelCheckResult["requirements"]): number {
const capHeightMm = requirements.netQuantityMinCapHeightMm;
if (row.basis === "netQuantity" && capHeightMm !== null) {
return fontSizeFor(capHeightMm, CAP_HEIGHT_RATIO);
}
return fontSizeFor(requirements.minXHeightMm, X_HEIGHT_RATIO);
}
/**
* One text element per particular the label needs, stacked top to bottom.
* The sizes follow `requirements` of the last check; no legal value is
* stored here. The elements carry only placeholders, so the printed text
* always comes from the master data.
*/
export function buildComplianceBlock(
template: { widthMm: number; heightMm: number },
requirements: LabelCheckResult["requirements"],
): LabelTextElement[] {
const width = Math.max(template.widthMm - 2 * EDGE_MM, 1);
let top = EDGE_MM;
return BLOCK_ROWS.map((row) => {
const fontSize = sizeOf(row, requirements);
const height = fontSize * MM_PER_PT * LINE_HEIGHT_FACTOR * row.lines;
const element: LabelTextElement = {
id: crypto.randomUUID(),
type: "text",
x: EDGE_MM,
y: top,
width,
height,
text: "",
placeholderKey: row.key,
fontSize,
fontFamily: "sans",
bold: false,
italic: false,
color: "#000000",
align: "left",
};
top += height + ROW_GAP_MM;
return element;
});
}
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
describe("placeholder keys", () => {
it("the frontend list equals the backend fixture ApiCore-Backend/test/fixtures/placeholder-keys.json", () => {
// The monorepo sits next to the frontend; vitest runs from ApiCore-Frontend.
const fixture = path.resolve(
process.cwd(),
"../ApiCore-Backend/test/fixtures/placeholder-keys.json",
);
const backendKeys: unknown = JSON.parse(readFileSync(fixture, "utf8"));
expect([...PLACEHOLDER_KEYS]).toEqual(backendKeys);
});
it("has a German label for every key", () => {
for (const key of PLACEHOLDER_KEYS) {
expect(PLACEHOLDER_LABELS[key]).toBeTruthy();
}
});
});
@@ -0,0 +1,37 @@
/**
* Placeholder keys a text element can carry. The backend owns the list
* (`PLACEHOLDER_KEYS`); `placeholder-keys.spec.ts` compares this copy with the
* backend fixture so the two never drift apart.
*/
export const PLACEHOLDER_KEYS = [
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine",
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
batchNumber: "Chargennummer",
honeyType: "Honigsorte",
lotNumber: "Los-/Chargennummer der Abfüllung",
bottlingDate: "Abfülldatum",
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
jarSize: "Glasgröße / Füllmenge",
productName: "Produktname",
legalName: "Verkehrsbezeichnung (z. B. Blütenhonig)",
originCountry: "Herkunftsland",
foodBusinessName: "Lebensmittelunternehmer: Name",
foodBusinessAddress: "Lebensmittelunternehmer: Anschrift",
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
lotLine: "Zeile „L Losnummer“",
};
@@ -1,27 +1,11 @@
import type { PlaceholderKey } from "./placeholder-keys";
export type LabelFontFamily = "sans" | "serif" | "mono";
export type LabelTextAlign = "left" | "center" | "right";
export type LabelLineOrientation = "horizontal" | "vertical";
export const PLACEHOLDER_KEYS = [
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
batchNumber: "Chargennummer",
honeyType: "Honigsorte",
lotNumber: "Los-/Chargennummer der Abfüllung",
bottlingDate: "Abfülldatum",
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
jarSize: "Glasgröße",
productName: "Produktname",
};
export { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
export type { PlaceholderKey } from "./placeholder-keys";
interface LabelElementBase {
id: string;
@@ -75,6 +59,8 @@ export interface LabelTemplate {
elements: LabelElement[];
widthMm: number;
heightMm: number;
/** Smallest x-height (mm) the check demands of a text; 0.5 to 5. */
minXHeightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
@@ -93,6 +79,7 @@ export interface CreateLabelTemplateInput {
elements: LabelElement[];
widthMm?: number;
heightMm?: number;
minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
@@ -108,6 +95,7 @@ export interface UpdateLabelTemplateInput {
elements?: LabelElement[];
widthMm?: number;
heightMm?: number;
minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
bookCorrection,
bookDisposal,
createLot,
createMedicine,
fetchLot,
fetchMedicine,
fetchMedicines,
fetchMedicineSuggestions,
fetchSelectableLots,
setMedicineActive,
updateLot,
updateMedicine,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => ({})) }));
function lastCall() {
const [path, init] = vi.mocked(apiFetch).mock.calls.at(-1) ?? [];
return { path, init: init as RequestInit | undefined };
}
describe("medicines api", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
it("lists all medicines or only the active/inactive ones", async () => {
await fetchMedicines();
expect(lastCall().path).toBe("/veterinary-medicines");
await fetchMedicines(false);
expect(lastCall().path).toBe("/veterinary-medicines?active=false");
});
it("reads suggestions and one medicine with its lots", async () => {
await fetchMedicineSuggestions();
expect(lastCall().path).toBe("/veterinary-medicines/suggestions");
await fetchMedicine("m 1");
expect(lastCall().path).toBe("/veterinary-medicines/m%201");
});
it("creates with an Idempotency-Key and patches without one", async () => {
await createMedicine({ name: "A", unit: "ML" }, { idempotencyKey: "k1" });
expect(lastCall().path).toBe("/veterinary-medicines");
expect(lastCall().init?.method).toBe("POST");
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k1" });
await updateMedicine("m1", { notes: null });
expect(lastCall().init?.method).toBe("PATCH");
expect(lastCall().init?.body).toBe(JSON.stringify({ notes: null }));
});
it("switches a medicine on and off through the lifecycle routes", async () => {
await setMedicineActive("m1", false);
expect(lastCall().path).toBe("/veterinary-medicines/m1/deactivate");
await setMedicineActive("m1", true);
expect(lastCall().path).toBe("/veterinary-medicines/m1/reactivate");
});
it("asks for selectable lots, keeping the chosen one with includeLotId", async () => {
await fetchSelectableLots("m1");
expect(lastCall().path).toBe("/veterinary-medicine-lots?medicineId=m1&selectable=true");
await fetchSelectableLots("m1", "l9");
expect(lastCall().path).toBe(
"/veterinary-medicine-lots?medicineId=m1&selectable=true&includeLotId=l9",
);
});
it("books a lot with an Idempotency-Key and posts the inventory and the disposal", async () => {
await fetchLot("l1");
expect(lastCall().path).toBe("/veterinary-medicine-lots/l1");
await createLot(
{ medicineId: "m1", purchasedOn: "2026-08-01", quantity: 5 },
{ idempotencyKey: "k2" },
);
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k2" });
await updateLot("l1", { supplier: null });
expect(lastCall().init?.method).toBe("PATCH");
await bookCorrection("l1", 3, { idempotencyKey: "k3" });
expect(lastCall().path).toBe("/veterinary-medicine-lots/l1/movements");
expect(lastCall().init?.body).toBe(JSON.stringify({ type: "CORRECTION", actualRemaining: 3 }));
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k3" });
await bookDisposal("l1", 2, "Abgelaufen", { idempotencyKey: "k4" });
expect(lastCall().init?.body).toBe(
JSON.stringify({ type: "DISPOSAL", quantity: 2, reason: "Abgelaufen" }),
);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
LotDetailView,
LotInput,
LotView,
MedicineInput,
MedicineSuggestion,
MedicineView,
} from "./types";
const MEDICINES = "/veterinary-medicines";
const LOTS = "/veterinary-medicine-lots";
function path(base: string, id: string, rest = ""): string {
return `${base}/${encodeURIComponent(id)}${rest}`;
}
function post<T>(url: string, body: unknown, options?: CreateOptions): Promise<T> {
return apiFetch<T>(url, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(body),
});
}
function patch<T>(url: string, body: unknown): Promise<T> {
return apiFetch<T>(url, { method: "PATCH", body: JSON.stringify(body) });
}
/** All medicines, or only the active (`true`) / inactive (`false`) ones. */
export function fetchMedicines(active?: boolean): Promise<MedicineView[]> {
return apiFetch<MedicineView[]>(active === undefined ? MEDICINES : `${MEDICINES}?active=${active}`);
}
/** Own product names from treatments that the catalogue does not know yet. */
export function fetchMedicineSuggestions(): Promise<MedicineSuggestion[]> {
return apiFetch<MedicineSuggestion[]>(`${MEDICINES}/suggestions`);
}
export function fetchMedicine(id: string): Promise<MedicineView & { lots: LotView[] }> {
return apiFetch<MedicineView & { lots: LotView[] }>(path(MEDICINES, id));
}
export function createMedicine(input: MedicineInput, options?: CreateOptions): Promise<MedicineView> {
return post<MedicineView>(MEDICINES, input, options);
}
export function updateMedicine(id: string, input: Partial<MedicineInput>): Promise<MedicineView> {
return patch<MedicineView>(path(MEDICINES, id), input);
}
export function setMedicineActive(id: string, active: boolean): Promise<MedicineView> {
return post<MedicineView>(path(MEDICINES, id, active ? "/reactivate" : "/deactivate"), {});
}
/**
* Lots a treatment may take from (rest above 0, active medicine, oldest expiry
* first). `includeLotId` keeps the lot of a treatment that is being edited on
* the list even when it is empty or its medicine was switched off.
*/
export function fetchSelectableLots(medicineId: string, includeLotId?: string): Promise<LotView[]> {
const params = new URLSearchParams({ medicineId, selectable: "true" });
if (includeLotId) params.set("includeLotId", includeLotId);
return apiFetch<LotView[]>(`${LOTS}?${params.toString()}`);
}
export function fetchLot(id: string): Promise<LotDetailView> {
return apiFetch<LotDetailView>(path(LOTS, id));
}
export function createLot(input: LotInput, options?: CreateOptions): Promise<LotView> {
return post<LotView>(LOTS, input, options);
}
export function updateLot(id: string, input: Partial<LotInput>): Promise<LotView> {
return patch<LotView>(path(LOTS, id), input);
}
/** Inventory: the rest counted in the box; the server books the difference. */
export function bookCorrection(
lotId: string,
actualRemaining: number,
options?: CreateOptions,
): Promise<LotDetailView> {
return post<LotDetailView>(path(LOTS, lotId, "/movements"), { type: "CORRECTION", actualRemaining }, options);
}
export function bookDisposal(
lotId: string,
quantity: number,
reason: string,
options?: CreateOptions,
): Promise<LotDetailView> {
return post<LotDetailView>(path(LOTS, lotId, "/movements"), { type: "DISPOSAL", quantity, reason }, options);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildBook } from "./book";
import { lotDetail } from "./fixtures.spec-helper";
const movement = (id: string, createdAt: string, quantity: number, type = "TREATMENT" as const) => ({
id,
type,
quantity,
treatmentId: null,
reason: null,
userId: null,
createdAt,
});
describe("buildBook", () => {
it("lists the newest booking first and ends at the receipt", () => {
const rows = buildBook(
lotDetail({
quantity: 20,
movements: [movement("b", "2026-08-12T08:00:00.000Z", -3), movement("a", "2026-08-10T08:00:00.000Z", -8)],
}),
);
expect(rows.map((row) => row.label)).toEqual(["Behandlung", "Behandlung", "Eingang"]);
expect(rows.map((row) => row.balance)).toEqual([9, 12, 20]);
});
it("keeps the running rest exact to the thousandth", () => {
const rows = buildBook(
lotDetail({
quantity: 1,
movements: [movement("a", "2026-08-10T08:00:00.000Z", -0.1), movement("b", "2026-08-11T08:00:00.000Z", -0.2)],
}),
);
expect(rows[0].balance).toBe(0.7);
});
it("lets a rest fall below zero and keeps the sign", () => {
const rows = buildBook(lotDetail({ quantity: 2, movements: [movement("a", "2026-08-10T08:00:00.000Z", -5)] }));
expect(rows[0].balance).toBe(-3);
});
it("reads the day of a booking as the Berlin calendar day", () => {
const rows = buildBook(lotDetail({ movements: [movement("a", "2026-08-10T22:30:00.000Z", -1)] }));
expect(rows[0].day).toBe("2026-08-11");
});
});
@@ -0,0 +1,50 @@
import { berlinCalendarDay } from "@/lib/dates";
import { MOVEMENT_TYPE_LABELS, roundToThousandths } from "./format";
import type { LotDetailView } from "./types";
export interface BookRow {
key: string;
/** Calendar day "YYYY-MM-DD". */
day: string;
label: string;
/** Signed. */
quantity: number;
/** Rest of the lot right after this booking. */
balance: number;
reason: string | null;
}
/**
* The movement book of one lot, newest first, each row with the rest it left
* behind. The first entry is the receipt of the pack (the initial quantity);
* the server never changes or deletes a movement, so the running rest always
* ends at `lot.remaining`.
*/
export function buildBook(lot: LotDetailView): BookRow[] {
const movements = [...lot.movements].sort(
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
);
const rows: BookRow[] = [
{
key: "receipt",
day: lot.purchasedOn,
label: "Eingang",
quantity: lot.quantity,
balance: lot.quantity,
reason: null,
},
];
let balance = lot.quantity;
for (const movement of movements) {
balance = roundToThousandths(balance + movement.quantity);
rows.push({
key: movement.id,
day: berlinCalendarDay(movement.createdAt),
label: MOVEMENT_TYPE_LABELS[movement.type],
quantity: movement.quantity,
balance,
reason: movement.reason,
});
}
return rows.reverse();
}
@@ -0,0 +1,79 @@
import type { LotDetailView, LotView, MedicineSuggestion, MedicineView } from "./types";
export function medicine(overrides: Partial<MedicineView> = {}): MedicineView {
return {
id: "m1",
name: "Apivar",
activeIngredient: "Amitraz",
authorisationNumber: null,
prescriptionOnly: false,
withdrawalPeriodDays: 0,
unit: "PIECE",
dosePerColony: null,
doseNote: null,
applicationMethod: null,
minTemperatureC: null,
maxTemperatureC: null,
defaultSupplier: null,
notes: null,
active: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
totalRemaining: 12,
nextExpiry: "2028-01-31",
lotCount: 1,
...overrides,
};
}
export function lot(overrides: Partial<LotView> = {}): LotView {
return {
id: "l1",
medicineId: "m1",
medicineName: "Apivar",
unit: "PIECE",
batchNumber: "CH-42",
purchasedOn: "2026-08-01",
quantity: 20,
remaining: 12,
expiresOn: "2028-01-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: null,
notes: null,
movementCount: 1,
...overrides,
};
}
export function lotDetail(overrides: Partial<LotDetailView> = {}): LotDetailView {
return {
...lot(),
movements: [
{
id: "mv1",
type: "TREATMENT",
quantity: -8,
treatmentId: "t1",
reason: null,
userId: "u1",
createdAt: "2026-08-10T08:00:00.000Z",
},
],
...overrides,
};
}
export function suggestion(overrides: Partial<MedicineSuggestion> = {}): MedicineSuggestion {
return {
name: "Thymovar",
treatmentCount: 3,
latest: {
activeIngredient: "Thymol",
withdrawalPeriodDays: 7,
method: "Streifen",
amount: "2 Stück",
},
...overrides,
};
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
decimalMessage,
formatDay,
formatQuantity,
formatSignedQuantity,
isAmbiguousDecimal,
parseDecimal,
THOUSANDS_SEPARATOR_MESSAGE,
} from "./format";
describe("medicine formatting", () => {
it("writes quantities with the unit and German decimals", () => {
expect(formatQuantity(12.5, "ML")).toBe("12,5 ml");
expect(formatQuantity(3, "G")).toBe("3 g");
expect(formatQuantity(2, "PIECE")).toBe("2 Stück");
});
it("writes a signed change with a real minus sign", () => {
expect(formatSignedQuantity(-2, "ML")).toBe("−2 ml");
expect(formatSignedQuantity(1.5, "G")).toBe("+1,5 g");
expect(formatSignedQuantity(0, "G")).toBe("0 g");
});
it("writes a calendar day as given, without a time zone shift", () => {
expect(formatDay("2028-01-31")).toBe("31.01.2028");
expect(formatDay(null)).toBe("—");
});
});
describe("parseDecimal", () => {
it.each([
["1,5", 1.5],
["1.5", 1.5],
["0.001", 0.001],
["1,000", 1],
["12", 12],
[" 2,5 ", 2.5],
])("reads %s as %s", (text, value) => {
expect(parseDecimal(text)).toBe(value);
});
it.each(["1.000", "12.345", "1.234,5", "1,234.5", "1.2.3", "1,2,3"])(
"refuses the ambiguous %s",
(text) => {
expect(isAmbiguousDecimal(text)).toBe(true);
expect(parseDecimal(text)).toBeNaN();
expect(decimalMessage(text, "x")).toBe(THOUSANDS_SEPARATOR_MESSAGE);
},
);
it("is NaN for empty or non-numeric text and keeps the plain message there", () => {
expect(parseDecimal("")).toBeNaN();
expect(parseDecimal("abc")).toBeNaN();
expect(decimalMessage("abc", "plain")).toBe("plain");
});
});
@@ -0,0 +1,73 @@
import type { MedicineUnit, MovementType } from "./types";
export const MEDICINE_UNITS: readonly MedicineUnit[] = ["ML", "G", "PIECE"];
export const MEDICINE_UNIT_LABELS: Record<MedicineUnit, string> = {
ML: "ml",
G: "g",
PIECE: "Stück",
};
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
TREATMENT: "Behandlung",
CORRECTION: "Inventur",
DISPOSAL: "Entsorgung",
};
const MAX_FRACTION_DIGITS = 3;
const MINUS = "−";
function number(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: MAX_FRACTION_DIGITS });
}
/** "12,5 ml": at most thousandths, German decimals. */
export function formatQuantity(value: number, unit: MedicineUnit): string {
return `${number(value).replace("-", MINUS)} ${MEDICINE_UNIT_LABELS[unit]}`;
}
/** "+1,5 g" / "−2 ml" / "0 g": the sign is part of the information. */
export function formatSignedQuantity(value: number, unit: MedicineUnit): string {
if (value > 0) return `+${formatQuantity(value, unit)}`;
return formatQuantity(value, unit);
}
/** A calendar day "YYYY-MM-DD" as "TT.MM.JJJJ", read as written (no time zone). */
export function formatDay(day: string | null): string {
if (!day) return "—";
const [year, month, date] = day.split("-");
return `${date}.${month}.${year}`;
}
/** Thousandths, as the server rounds, so the shown delta equals the booked one. */
export function roundToThousandths(value: number): number {
return Math.round(value * 1000) / 1000 + 0;
}
export const THOUSANDS_SEPARATOR_MESSAGE = "Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5";
/**
* Locale rule: a comma is always the German decimal separator ("1,000" is 1).
* A dot is a decimal point only when it is the single separator and does not
* look like a thousands dot, so "1.5" and "0.001" pass, while "1.000", "12.345"
* and any text with more than one separator are ambiguous and refused (a
* silently wrong quantity in a medicine book is worse than a question).
*/
export function isAmbiguousDecimal(text: string): boolean {
const trimmed = text.trim().replace(/\s/g, "");
const separators = trimmed.match(/[.,]/g)?.length ?? 0;
if (separators > 1) return true;
return /^\d*[1-9]\d*\.\d{3}$/.test(trimmed);
}
/** Accepts "2,5" as well as "2.5"; anything else, or an ambiguous thousands dot, is NaN. */
export function parseDecimal(text: string): number {
if (isAmbiguousDecimal(text)) return Number.NaN;
const trimmed = text.trim().replace(",", ".");
return trimmed === "" ? Number.NaN : Number(trimmed);
}
/** The message for a typed number that parses to nothing: the thousands hint or the plain one. */
export function decimalMessage(text: string, plain: string): string {
return isAmbiguousDecimal(text) ? THOUSANDS_SEPARATOR_MESSAGE : plain;
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { prefillFromSuggestion, unitFromAmount } from "./prefill";
import { suggestion } from "./fixtures.spec-helper";
describe("unitFromAmount", () => {
it.each([
["30 ml", "ML"],
["2 Stück", "PIECE"],
["2 Streifen", "PIECE"],
["5 g", "G"],
["5 Gramm", "G"],
])("reads the unit of %s", (amount, unit) => {
expect(unitFromAmount(amount)).toBe(unit);
});
it("names no unit when the text names none", () => {
expect(unitFromAmount("eine Dosis")).toBeUndefined();
expect(unitFromAmount(null)).toBeUndefined();
});
});
describe("prefillFromSuggestion", () => {
it("carries the values of the latest treatment", () => {
expect(prefillFromSuggestion(suggestion())).toEqual({
name: "Thymovar",
unit: "PIECE",
activeIngredient: "Thymol",
withdrawalPeriodDays: 7,
applicationMethod: "Streifen",
doseNote: "Menge der letzten Behandlung: 2 Stück",
});
});
it("leaves the dose note empty when the treatment had no amount", () => {
const result = prefillFromSuggestion(
suggestion({ latest: { activeIngredient: null, withdrawalPeriodDays: null, method: null, amount: null } }),
);
expect(result.doseNote).toBeNull();
expect(result.unit).toBeUndefined();
});
});
@@ -0,0 +1,42 @@
import type { MedicineSuggestion, MedicineUnit } from "./types";
/** What the form starts with when a medicine is taken over from a treatment. */
export interface MedicineFormPrefill {
name?: string;
unit?: MedicineUnit;
activeIngredient?: string | null;
withdrawalPeriodDays?: number | null;
applicationMethod?: string | null;
doseNote?: string | null;
}
const MAX_DOSE_NOTE_LENGTH = 400;
const UNIT_HINTS: readonly [RegExp, MedicineUnit][] = [
[/\bml\b/i, "ML"],
[/\b(stück|stueck|stk|streifen)\b/i, "PIECE"],
[/\b(g|gramm)\b/i, "G"],
];
/** The unit the amount text of the latest treatment names, or nothing when it names none. */
export function unitFromAmount(amount: string | null): MedicineUnit | undefined {
if (!amount) return undefined;
return UNIT_HINTS.find(([pattern]) => pattern.test(amount))?.[1];
}
/**
* The values of the latest treatment as a proposal for the form. They are
* shown, never saved by themselves: the beekeeper checks them against the
* package leaflet first.
*/
export function prefillFromSuggestion(suggestion: MedicineSuggestion): MedicineFormPrefill {
const { latest } = suggestion;
return {
name: suggestion.name,
unit: unitFromAmount(latest.amount),
activeIngredient: latest.activeIngredient,
withdrawalPeriodDays: latest.withdrawalPeriodDays,
applicationMethod: latest.method,
doseNote: latest.amount ? `Menge der letzten Behandlung: ${latest.amount}`.slice(0, MAX_DOSE_NOTE_LENGTH) : null,
};
}
+101
View File
@@ -0,0 +1,101 @@
export type MedicineUnit = "ML" | "G" | "PIECE";
export type MovementType = "TREATMENT" | "CORRECTION" | "DISPOSAL";
/** Days without a time of day travel as "YYYY-MM-DD". */
export interface MedicineView {
id: string;
name: string;
activeIngredient: string | null;
authorisationNumber: string | null;
prescriptionOnly: boolean;
/** Days of withdrawal for honey, 0 is a value. */
withdrawalPeriodDays: number | null;
unit: MedicineUnit;
dosePerColony: number | null;
doseNote: string | null;
applicationMethod: string | null;
minTemperatureC: number | null;
maxTemperatureC: number | null;
defaultSupplier: string | null;
notes: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
/** Sum of the rest of all lots; a lot below zero counts negative. */
totalRemaining: number;
/** Earliest expiry of a lot that still has a rest. */
nextExpiry: string | null;
lotCount: number;
}
export interface LotView {
id: string;
medicineId: string;
medicineName: string;
unit: MedicineUnit;
batchNumber: string | null;
purchasedOn: string;
quantity: number;
/** Quantity plus all movements; may be negative. */
remaining: number;
expiresOn: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
notes: string | null;
movementCount: number;
}
export interface MovementView {
id: string;
type: MovementType;
/** Signed: negative takes medicine out of the lot. */
quantity: number;
treatmentId: string | null;
reason: string | null;
userId: string | null;
createdAt: string;
}
export interface LotDetailView extends LotView {
movements: MovementView[];
}
export interface MedicineSuggestion {
name: string;
treatmentCount: number;
latest: {
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
method: string | null;
amount: string | null;
};
}
export interface MedicineInput {
name: string;
unit: MedicineUnit;
activeIngredient?: string | null;
authorisationNumber?: string | null;
prescriptionOnly?: boolean;
withdrawalPeriodDays?: number | null;
dosePerColony?: number | null;
doseNote?: string | null;
applicationMethod?: string | null;
minTemperatureC?: number | null;
maxTemperatureC?: number | null;
defaultSupplier?: string | null;
notes?: string | null;
}
export interface LotInput {
medicineId: string;
batchNumber?: string | null;
purchasedOn: string;
quantity: number;
expiresOn?: string | null;
supplier?: string | null;
purchaseProof?: string | null;
prescribingVet?: string | null;
notes?: string | null;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { ApiError } from "@/lib/auth/api-client";
import { useAttemptKey } from "./use-attempt-key";
describe("useAttemptKey", () => {
it("keeps the key for an unchanged retry after a network error", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new TypeError("Failed to fetch"));
expect(result.current.keyFor({ quantity: 1 })).toBe(first);
});
it("renews the key when the body changes", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new TypeError("Failed to fetch"));
expect(result.current.keyFor({ quantity: 2 })).not.toBe(first);
});
it("renews the key after an answer from the server, even for the same body", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new ApiError(409, "Gerade geändert.", {}));
expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
});
it("renews the key after a success", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.done();
expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
});
});
@@ -0,0 +1,39 @@
import { useRef } from "react";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
/**
* The Idempotency-Key of one booking form. The key is kept only for an
* unchanged retry after a network error (the server may have saved the first
* try). Any answer from the server that is not a success, and any change of
* the request body, starts a new key: a changed booking must never be taken
* for its earlier attempt.
*/
export function useAttemptKey(): {
keyFor: (body: unknown) => string;
failed: (error: unknown) => void;
done: () => void;
} {
const key = useRef(newIdempotencyKey());
const lastBody = useRef<string | null>(null);
return {
keyFor(body) {
const text = JSON.stringify(body);
if (lastBody.current !== null && lastBody.current !== text) {
key.current = newIdempotencyKey();
}
lastBody.current = text;
return key.current;
},
failed(error) {
if (!isNetworkError(error)) {
key.current = newIdempotencyKey();
lastBody.current = null;
}
},
done() {
key.current = newIdempotencyKey();
lastBody.current = null;
},
};
}
@@ -18,6 +18,7 @@ interface SwRoutes {
isShellRoute: (pathname: string) => boolean;
extractAssetUrls: (html: string) => string[];
isCacheableApiPath: (pathname: string) => boolean;
isKeepableApiAnswer: (answer: { ok: boolean; contentType: string | null; cacheControl: string | null }) => boolean;
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
apiOriginFrom: (config: unknown) => string | null;
isRoutePayload: (url: URL) => boolean;
@@ -224,6 +225,100 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/exporters")).toBe(true);
});
it.each([
"/api/v1/compliance/readiness",
"/api/v1/compliance",
"/api/v1/Compliance/readiness/summary",
"/api/v1/compliance/readiness/report.pdf",
"/api/v1/authority-notices",
"/api/v1/authority-notices/",
"/api/v1/authority-notices/n-1",
"/api/v1/apiary-restrictions",
"/api/v1/apiary-restrictions/r-1",
"/api/v1/apiaries/a-1/restrictions",
"/api/v1/apiaries/a-1/restrictions/",
"/api/v1/veterinary-medicines",
"/api/v1/veterinary-medicines/m-1",
"/api/v1/veterinary-medicine-lots",
"/api/v1/Veterinary-Medicine-Lots/l-1",
"/api/v1/label-templates/t-1/check/b-1",
"/api/v1/label-templates/t-1/check/b-1/",
"/api/v1/label-templates/check",
"/api/v1/label-templates/t-1/print/b-1",
"/api/v1/label-templates/t-1/print",
"/api/v1/compliance/readiness?x=1",
])("never caches %s", (path) => {
expect(cachesApi(path)).toBe(false);
});
it("never caches the documents of a medicine lot, a notice or a restriction", () => {
for (const entityType of ["MEDICINE_LOT", "AUTHORITY_NOTICE", "APIARY_RESTRICTION", "medicine_lot"]) {
expect(cachesApi(`/api/v1/documents?entityType=${entityType}&entityId=x-1`)).toBe(false);
expect(cachesApi(`/api/v1/documents/?entityId=x-1&entityType=${entityType}`)).toBe(false);
}
});
it("keeps caching the document lists of inspections, so their photos stay available offline", () => {
expect(cachesApi("/api/v1/documents?entityType=INSPECTION&entityId=i-1")).toBe(true);
expect(cachesApi("/api/v1/documents?entityType=COLONY&entityId=c-1")).toBe(true);
});
it("never caches a single document record, whose kind the path does not tell", () => {
expect(cachesApi("/api/v1/documents/d-1")).toBe(false);
expect(cachesApi("/api/v1/documents/d-1/")).toBe(false);
expect(cachesApi("/api/v1/Documents/d-1")).toBe(false);
// the download is a blob and is never kept anyway; its route is not touched
expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
});
it("keeps caching the unrelated routes it cached before", () => {
for (const path of [
"/api/v1/tasks",
"/api/v1/colonies/c-1",
"/api/v1/products",
"/api/v1/label-templates",
"/api/v1/label-templates/t-1",
"/api/v1/compliances",
"/api/v1/veterinary-medicines-overview",
"/api/v1/apiaries/a-1/weather",
]) {
expect(cachesApi(path)).toBe(true);
}
});
it("keeps a cacheable path whose answer carries the real header Cache-Control: no-store", () => {
// The backend marks every answer no-store; the offline packs depend on keeping them.
const answer = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: "no-store" };
for (const path of ["/api/v1/colonies/c-1", "/api/v1/tasks", "/api/v1/apiaries/a-1/weather"]) {
expect(cachesApi(path) && routes.isKeepableApiAnswer(answer)).toBe(true);
}
});
it("does not keep the never-cached paths, whatever the header says", () => {
const answer = { ok: true, contentType: "application/json", cacheControl: "no-store" };
expect(routes.isKeepableApiAnswer(answer)).toBe(true);
for (const path of [
"/api/v1/compliance/readiness",
"/api/v1/authority-notices/n-1",
"/api/v1/veterinary-medicine-lots/l-1",
"/api/v1/apiaries/a-1/restrictions",
"/api/v1/documents/d-1",
"/api/v1/documents?entityType=MEDICINE_LOT&entityId=l-1",
"/api/v1/apiaries/a-1/offline-pack",
"/api/v1/colonies",
]) {
expect(cachesApi(path)).toBe(false);
}
});
it("keeps only successful JSON answers", () => {
const json = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: null };
expect(routes.isKeepableApiAnswer(json)).toBe(true);
expect(routes.isKeepableApiAnswer({ ...json, ok: false })).toBe(false);
expect(routes.isKeepableApiAnswer({ ...json, contentType: "application/pdf" })).toBe(false);
expect(routes.isKeepableApiAnswer({ ...json, contentType: null })).toBe(false);
});
it("caches only answers of the configured backend", () => {
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
@@ -13,6 +13,8 @@ export interface Product {
packagingSize: number | null;
/** Months the honey stays good; the bottling form derives its best-before day from it. */
shelfLifeMonths: number | null;
/** Name on the label, e.g. „Blütenhonig“. Up to 200 characters. */
legalName: string | null;
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
@@ -30,6 +32,7 @@ export interface CreateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -44,6 +47,7 @@ export interface UpdateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -6,6 +6,10 @@ export interface SiteSettings {
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
/** Name on the label: the person who puts the honey on the market. Up to 200 characters. */
foodBusinessName: string | null;
/** Address on the label. Up to 300 characters. Never on public pages. */
foodBusinessAddress: string | null;
originCountry: string;
updatedAt: string;
}
@@ -17,5 +21,8 @@ export interface UpdateSiteSettingsInput {
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
/** `null` clears the value. */
foodBusinessName?: string | null;
foodBusinessAddress?: string | null;
originCountry?: string;
}
@@ -73,4 +73,13 @@ describe("DOMAIN_EVENT_LABELS completeness", () => {
expect(types.length).toBeGreaterThan(20);
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
});
it("DOMAIN_EVENT_LABELS names ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached", () => {
expect(DOMAIN_EVENT_LABELS).toMatchObject({
ColonyCreated: "Volk angelegt",
ColonyMoved: "Volk umgesetzt",
ApiaryChanged: "Stand geändert",
TskReferenceDayReached: "TSK-Stichtag erreicht",
});
});
});