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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 15:59:08 +02:00
1 parent b912d29166
commit b4746a2b17
149 files changed
+11869 -320

No files matched your search

@@ -133,4 +133,20 @@ describe("DocumentUploadCard", () => {
);
expect(outerSubmit).not.toHaveBeenCalled();
});
it("offers no upload, rename or delete when canWrite is false, but still downloads", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<DocumentUploadCard entityType="INSPECTION" entityId="i-1" canWrite={false} />
</QueryClientProvider>,
);
expect(await screen.findByText("rahmen.jpg")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Herunterladen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Datei hochladen/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Umbenennen/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Löschen" })).not.toBeInTheDocument();
});
});
@@ -150,12 +150,15 @@ interface DocumentUploadCardProps {
entityType: DocumentEntityType;
entityId: string;
title?: string;
/** False: only look at and download the files (no upload, rename or delete). */
canWrite?: boolean;
}
export function DocumentUploadCard({
entityType,
entityId,
title = "Dokumente",
canWrite = true,
}: DocumentUploadCardProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -205,22 +208,26 @@ export function DocumentUploadCard({
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
{uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
{canWrite ? (
<>
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
{uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
</>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
@@ -228,7 +235,9 @@ export function DocumentUploadCard({
<EmptyState
icon={Paperclip}
title="Noch keine Dateien"
description="Lade Fotos oder Dokumente hoch, um sie hier zu sammeln."
description={
canWrite ? "Lade Fotos oder Dokumente hoch, um sie hier zu sammeln." : "Hier liegen noch keine Dateien."
}
/>
) : null}
{documents?.map((document) => (
@@ -258,8 +267,8 @@ export function DocumentUploadCard({
>
Herunterladen
</Button>
<RenameDocumentDialog document={document} />
<ConfirmDialog
{canWrite ? <RenameDocumentDialog document={document} /> : null}
{canWrite ? <ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
@@ -269,7 +278,7 @@ export function DocumentUploadCard({
confirmLabel="Löschen"
isPending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(document.id)}
/>
/> : null}
</div>
</div>
))}
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { LabelCompliancePanel } from "./label-compliance-panel";
import type { LabelCheckResult, LabelFinding } from "@/lib/label-templates/check";
function finding(code: LabelFinding["code"], title: string, detail = ""): LabelFinding {
return { code, title, detail, elementIds: [] };
}
const COMPLETE: LabelCheckResult = {
printable: true,
gaps: [],
warnings: [],
sample: false,
requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: 3 },
};
function items(): HTMLElement[] {
return within(screen.getByRole("list", { name: "Pflichtangaben" })).getAllByRole("listitem");
}
describe("LabelCompliancePanel", () => {
it("shows each particular with a check mark or a cross and the word Erfüllt or Fehlt", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
printable: false,
gaps: [
finding("ORIGIN", "Herkunftsland fehlt", "Das Herkunftsland steht nicht auf dem Etikett."),
finding("LOT", "Losnummer fehlt"),
],
}}
pending={false}
error={null}
/>,
);
expect(items()).toHaveLength(6);
const origin = items().find((item) => /Herkunftsland/.test(item.textContent ?? ""))!;
expect(origin).toHaveTextContent("Fehlt");
expect(origin).toHaveTextContent("Das Herkunftsland steht nicht auf dem Etikett.");
expect(origin.querySelector("svg")).not.toBeNull();
const legalName = items().find((item) => /Verkehrsbezeichnung/.test(item.textContent ?? ""))!;
expect(legalName).toHaveTextContent("Erfüllt");
expect(legalName).not.toHaveTextContent("Fehlt");
expect(legalName.querySelector("svg")).not.toBeNull();
expect(items().filter((item) => /Fehlt/.test(item.textContent ?? ""))).toHaveLength(2);
expect(items().filter((item) => /Erfüllt/.test(item.textContent ?? ""))).toHaveLength(4);
});
it("lists warnings separately and announces changes via aria-live polite", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
warnings: [
finding("X_HEIGHT", "Text zu klein (x-Höhe 1,1 mm)", "Mindestens 1,2 mm."),
finding("TEXT_CLIPPED", "Text abgeschnitten"),
],
}}
pending={false}
error={null}
/>,
);
const warnings = screen.getByRole("list", { name: "Hinweise" });
expect(within(warnings).getAllByRole("listitem")).toHaveLength(2);
expect(within(warnings).getByText("Hinweis: Text zu klein (x-Höhe 1,1 mm)")).toBeInTheDocument();
// The warnings are not part of the particulars list.
expect(items()).toHaveLength(6);
// Changes are announced through the summary line only.
expect(screen.getByText("Alle Pflichtangaben sind erfüllt.")).toHaveAttribute("aria-live", "polite");
});
it('shows "Beispielwerte" when the result is a sample', () => {
render(<LabelCompliancePanel result={{ ...COMPLETE, sample: true }} pending={false} error={null} />);
expect(screen.getByText(/Beispielwerte/)).toBeInTheDocument();
});
it("does not mention sample values for a real bottling", () => {
render(<LabelCompliancePanel result={COMPLETE} pending={false} error={null} />);
expect(screen.queryByText(/Beispielwerte/)).not.toBeInTheDocument();
});
it("takes the legal minimums from the result and states none of its own", () => {
render(
<LabelCompliancePanel
result={{ ...COMPLETE, requirements: { minXHeightMm: 1.7, netQuantityMinCapHeightMm: 4 } }}
pending={false}
error={null}
/>,
);
expect(screen.getByText(/1,7 mm/)).toBeInTheDocument();
expect(screen.getByText(/4 mm/)).toBeInTheDocument();
});
it("keeps the live region while the check runs and says so", () => {
const { container } = render(<LabelCompliancePanel result={null} pending error={null} />);
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
expect(screen.getByText(/wird geprüft/i)).toBeInTheDocument();
});
it("puts only the summary line in the live region", () => {
const { container } = render(
<LabelCompliancePanel
result={{
...COMPLETE,
printable: false,
gaps: [finding("LOT", "Losnummer fehlt")],
warnings: [finding("TEXT_CLIPPED", "Text abgeschnitten")],
}}
pending={false}
error={null}
/>,
);
const live = container.querySelectorAll('[aria-live="polite"]');
expect(live).toHaveLength(1);
expect(live[0]).toHaveTextContent("Es fehlt 1 Pflichtangabe.");
expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Pflichtangaben" }));
expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Hinweise" }));
});
it("prefixes every warning with Hinweis:", () => {
render(
<LabelCompliancePanel
result={{
...COMPLETE,
warnings: [finding("X_HEIGHT", "Text zu klein"), finding("TEXT_CLIPPED", "Text abgeschnitten")],
}}
pending={false}
error={null}
/>,
);
const items = within(screen.getByRole("list", { name: "Hinweise" })).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("Hinweis: Text zu klein");
expect(items[1]).toHaveTextContent("Hinweis: Text abgeschnitten");
});
it("says the result is veraltet when it answers an older draft", () => {
render(<LabelCompliancePanel result={COMPLETE} pending={false} error={null} stale />);
expect(screen.getByText(/Veraltet/)).toBeInTheDocument();
});
it("shows an error as an alert", () => {
render(<LabelCompliancePanel result={null} pending={false} error="Die Prüfung ist fehlgeschlagen." />);
expect(screen.getByRole("alert")).toHaveTextContent("Die Prüfung ist fehlgeschlagen.");
});
});
@@ -0,0 +1,142 @@
"use client";
import { CircleCheck, CircleX, Loader, TriangleAlert } from "lucide-react";
import {
PARTICULAR_CODES,
type LabelCheckResult,
type LabelFinding,
type LabelFindingCode,
} from "@/lib/label-templates/check";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const PARTICULAR_LABELS: Record<(typeof PARTICULAR_CODES)[number], string> = {
LEGAL_NAME: "Verkehrsbezeichnung",
NET_QUANTITY: "Füllmenge",
BEST_BEFORE: "Mindesthaltbarkeitsdatum",
LOT: "Losnummer",
ORIGIN: "Herkunftsland",
FOOD_BUSINESS: "Name und Anschrift des Lebensmittelunternehmers",
};
function formatMm(value: number): string {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm`;
}
function summaryOf(result: LabelCheckResult | null, pending: boolean): string | null {
if (pending) return "Das Etikett wird geprüft …";
if (!result) return null;
const missing = result.gaps.length;
if (missing === 0) return "Alle Pflichtangaben sind erfüllt.";
return missing === 1 ? "Es fehlt 1 Pflichtangabe." : `Es fehlen ${missing} Pflichtangaben.`;
}
function ParticularRow({ code, gap }: { code: LabelFindingCode; gap: LabelFinding | undefined }) {
const label = PARTICULAR_LABELS[code as keyof typeof PARTICULAR_LABELS];
return (
<li className="flex items-start gap-2 text-sm">
{gap ? (
<CircleX aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
) : (
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-success" />
)}
<span className="flex flex-col">
<span>
<span className="font-medium">{label}:</span>{" "}
<span className={gap ? "font-medium text-destructive" : "text-muted-foreground"}>
{gap ? "Fehlt" : "Erfüllt"}
</span>
</span>
{gap ? <span className="text-muted-foreground">{gap.detail || gap.title}</span> : null}
</span>
</li>
);
}
interface LabelCompliancePanelProps {
result: LabelCheckResult | null;
pending: boolean;
error: string | null;
/** `result` answers an older draft; the current one cannot be checked. */
stale?: boolean;
}
/**
* Shows the answer of the label check: the six particulars with symbol and
* word, the warnings apart (they never block printing) and the minimums the
* server used. The panel decides nothing itself.
*/
export function LabelCompliancePanel({
result,
pending,
error,
stale = false,
}: LabelCompliancePanelProps) {
const summary = stale
? "Veraltet: Dieser Stand passt nicht mehr zu deinen Eingaben."
: summaryOf(result, pending);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Pflichtangaben</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<div className="flex flex-col gap-3">
{summary ? (
<p aria-live="polite" className="flex items-center gap-2 text-sm font-medium">
{pending ? <Loader aria-hidden className="size-4 animate-spin motion-reduce:animate-none" /> : null}
{summary}
</p>
) : null}
{result ? (
<>
{result.sample ? (
<p className="text-sm text-muted-foreground">
Geprüft mit Beispielwerten, nicht mit einer echten Abfüllung.
</p>
) : null}
<ul aria-label="Pflichtangaben" className="flex flex-col gap-2">
{PARTICULAR_CODES.map((code) => (
<ParticularRow
key={code}
code={code}
gap={result.gaps.find((gap) => gap.code === code)}
/>
))}
</ul>
<p className="text-xs text-muted-foreground">
Mindest-x-Höhe: {formatMm(result.requirements.minXHeightMm)}
{result.requirements.netQuantityMinCapHeightMm !== null
? `. Ziffernhöhe der Füllmenge: mindestens ${formatMm(result.requirements.netQuantityMinCapHeightMm)}`
: ""}
.
</p>
{result.warnings.length > 0 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Hinweise (sie sperren den Druck nicht)</p>
<ul aria-label="Hinweise" className="flex flex-col gap-2">
{result.warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
<span className="flex flex-col">
<span className="font-medium">Hinweis: {warning.title}</span>
{warning.detail ? (
<span className="text-muted-foreground">{warning.detail}</span>
) : null}
</span>
</li>
))}
</ul>
</div>
) : null}
</>
) : null}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,18 @@
import Link from "next/link";
/** Shown instead of a page whose data needs `veterinary-medicines:read`: no retry, it would not help. */
export function NoReadAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
<Link
href="/storage"
className="inline-flex min-h-11 items-center self-start text-sm underline underline-offset-2"
>
Zurück zum Lager
</Link>
</main>
);
}
@@ -0,0 +1,20 @@
import { AlertTriangle } from "lucide-react";
import { formatQuantity } from "@/lib/medicines/format";
import type { MedicineUnit } from "@/lib/medicines/types";
/**
* A rest of a medicine. Below zero it is red AND carries a symbol and the
* word "Negativ": the treatment happened, the book has a gap, and the state
* must not rely on colour alone.
*/
export function RestText({ value, unit }: { value: number; unit: MedicineUnit }) {
if (value < 0) {
return (
<span className="inline-flex max-w-full items-start gap-1 font-semibold whitespace-normal text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
{formatQuantity(value, unit)} · Negativ
</span>
);
}
return <span>{formatQuantity(value, unit)}</span>;
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ComplianceLine } from "./compliance-line";
import userEvent from "@testing-library/user-event";
import { ApiError } from "@/lib/auth/api-client";
import { fetchReadinessSummary } from "@/lib/compliance/api";
let permissions = ["compliance:read"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/compliance/api", () => ({ fetchReadinessSummary: vi.fn() }));
function renderLine() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ComplianceLine />
</QueryClientProvider>,
);
}
describe("ComplianceLine", () => {
beforeEach(() => {
permissions = ["compliance:read"];
vi.mocked(fetchReadinessSummary).mockReset();
});
it("renders only when openCount > 0 and the user holds compliance:read", async () => {
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 2, firstItemKey: "holder-data" });
renderLine();
const link = await screen.findByRole("link", { name: /2 Punkte offen/ });
expect(link).toHaveAttribute("href", "/compliance");
expect(link).toHaveTextContent("Prüfbereitschaft");
});
it("stays away when nothing is open", async () => {
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null });
const { container } = renderLine();
await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("does not even ask without compliance:read", () => {
permissions = [];
const { container } = renderLine();
expect(fetchReadinessSummary).not.toHaveBeenCalled();
expect(container).toBeEmptyDOMElement();
});
it("says quietly that it was not loaded and offers a retry", async () => {
vi.mocked(fetchReadinessSummary).mockRejectedValueOnce(new ApiError(500, "Serverfehler"));
renderLine();
expect(await screen.findByText("Prüfbereitschaft nicht geladen.")).toBeInTheDocument();
vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 1, firstItemKey: "k" });
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("link", { name: /1 Punkt offen/ })).toBeInTheDocument();
});
it("stays away when the device is offline", async () => {
vi.mocked(fetchReadinessSummary).mockRejectedValue(new TypeError("Failed to fetch"));
const { container } = renderLine();
await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { ChevronRight, RotateCw, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { useAuth } from "@/lib/auth/auth-context";
import { useReadinessSummary } from "@/lib/compliance/use-readiness";
/**
* One line on the dashboard when the Prüfbereitschaft has open items. Nothing
* at all otherwise, without the right to read it, or when it cannot be loaded.
*/
export function ComplianceLine() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const { data, error, isError, isFetching, refetch } = useReadinessSummary(canRead);
if (!canRead) return null;
// Offline is normal on the dashboard; any other failure is said, quietly.
if (!data && isError && !isNetworkError(error)) {
return (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<p>Prüfbereitschaft nicht geladen.</p>
<Button type="button" size="touch" variant="ghost" disabled={isFetching} onClick={() => void refetch()}>
<RotateCw aria-hidden />
Erneut versuchen
</Button>
</div>
);
}
if (!data || data.openCount <= 0) return null;
const count = data.openCount === 1 ? "1 Punkt offen" : `${data.openCount} Punkte offen`;
return (
<Link
href="/compliance"
className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm outline-none hover:bg-destructive/10 focus-visible:ring-3 focus-visible:ring-ring/50"
>
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden />
<span className="flex-1">
<span className="font-semibold text-destructive">{count}</span> in der Prüfbereitschaft
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
}