feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
- 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:
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,
|
||||
|
||||
+202
-4
@@ -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 }));
|
||||
});
|
||||
});
|
||||
+127
-5
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user