- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern, Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte. - AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build neben dem 3D-Budget (Spec 17.6). - PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5); Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk. - Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full. - Kein transition-all, kein Hover-Heben, keine Breiten-Animation von Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
"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 motion-full:animate-spin" /> : 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-ink" />
|
|
<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>
|
|
);
|
|
}
|