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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user