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

@@ -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>
);
}