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 (
|
||||
|
||||
Reference in new issue
Block a user