fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6c390efe7b
commit
e712c5d4d1
64 files changed
+1497
-323
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NoComplianceAccess } from "./no-compliance-access";
|
||||
|
||||
describe("NoComplianceAccess", () => {
|
||||
it("names the area in a heading above the missing-right text", () => {
|
||||
render(<NoComplianceAccess />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Prüfbereitschaft" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Zurück zum Start" })).toHaveAttribute("href", "/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
export function NoComplianceAccess() {
|
||||
return (
|
||||
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
||||
<h1 className="font-heading text-xl font-semibold tracking-tight">Prüfbereitschaft</h1>
|
||||
<p role="status" className="text-sm">
|
||||
Dazu fehlt dir das Recht.
|
||||
</p>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Field({
|
||||
const Caption = group ? "span" : "label";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<Caption
|
||||
id={`${id}-label`}
|
||||
htmlFor={group ? undefined : id}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface FormSectionProps {
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
fieldCount?: number;
|
||||
/** Shows the content although the section is folded, e.g. while a field inside has a problem. */
|
||||
forceOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -27,9 +29,11 @@ export function FormSection({
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
fieldCount,
|
||||
forceOpen = false,
|
||||
children,
|
||||
}: FormSectionProps) {
|
||||
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
|
||||
const [openState, setOpen] = useState(collapsible ? defaultOpen : true);
|
||||
const open = openState || forceOpen;
|
||||
const heading = (
|
||||
<span className="flex items-center gap-2">
|
||||
{step !== undefined ? (
|
||||
@@ -45,7 +49,7 @@ export function FormSection({
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
|
||||
<fieldset className="flex min-w-0 flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
|
||||
{collapsible ? (
|
||||
<legend className="contents">
|
||||
<button
|
||||
@@ -77,7 +81,7 @@ export function FormSection({
|
||||
{description && open ? (
|
||||
<p className="-mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
{open ? <div className="flex flex-col gap-4">{children}</div> : null}
|
||||
{open ? <div className="flex min-w-0 flex-col gap-4">{children}</div> : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -124,4 +124,14 @@ describe("RecordSelect", () => {
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
|
||||
});
|
||||
|
||||
it("truncates a long chosen label in the trigger instead of widening the form", () => {
|
||||
render(<Harness initial="cmg3xabc" />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
expect(trigger.className).toContain("min-w-0");
|
||||
const label = screen.getByText("Obstwiese");
|
||||
expect(label.className).toContain("truncate");
|
||||
expect(label.className).toContain("min-w-0");
|
||||
});
|
||||
});
|
||||
@@ -82,12 +82,15 @@ export function RecordSelect<T extends { id: string }>({
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
data-field={dataField}
|
||||
className={cn("h-11 w-full", className)}
|
||||
className={cn("h-11 w-full min-w-0", className)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
|
||||
<SelectValue placeholder={placeholder} className="min-w-0">
|
||||
{/* A long label is cut with an ellipsis; the list shows it in full. */}
|
||||
<span className="min-w-0 truncate" data-slot="record-select-label">
|
||||
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { formatDay } from "@/lib/medicines/format";
|
||||
|
||||
/**
|
||||
* A "best before" day of a medicine or pack. A day in the past is red AND
|
||||
* carries a symbol and the word "abgelaufen", so the state does not rely on
|
||||
* colour alone. The day itself still counts as valid.
|
||||
*/
|
||||
export function ExpiryText({ day, today }: { day: string | null; today?: string }) {
|
||||
if (day && day < (today ?? todayLocalIsoDate())) {
|
||||
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 />
|
||||
{formatDay(day)} · abgelaufen
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{formatDay(day)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NoReadAccess } from "./no-read-access";
|
||||
|
||||
describe("NoReadAccess", () => {
|
||||
it("names the area in a heading above the missing-right text", () => {
|
||||
render(<NoReadAccess />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Tierarzneimittel" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
export function NoReadAccess() {
|
||||
return (
|
||||
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
||||
<h1 className="font-heading text-xl font-semibold tracking-tight">Tierarzneimittel</h1>
|
||||
<p role="status" className="text-sm">
|
||||
Dazu fehlt dir das Recht.
|
||||
</p>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
|
||||
import type { LotView, MedicineView } from "@/lib/medicines/types";
|
||||
import { fetchApiaryWeather } from "@/lib/weather/api";
|
||||
import { bookingLine } from "@/lib/treatments/booking-line";
|
||||
import { parsedDose } from "@/lib/treatments/dose";
|
||||
import { doseFormatError, parsedDose } from "@/lib/treatments/dose";
|
||||
import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
|
||||
import { temperatureHint } from "@/lib/treatments/temperature-hint";
|
||||
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
|
||||
@@ -51,6 +51,8 @@ export interface MedicineLotFieldsProps {
|
||||
doseQuantity: string;
|
||||
productError?: string;
|
||||
doseError?: string;
|
||||
/** A note under the dose, e.g. that it was cleared because the unit changed. */
|
||||
doseNotice?: string;
|
||||
/** Which fields carry the note „aus Katalog“. */
|
||||
filled: CatalogueFilled;
|
||||
onProductChange: (product: string) => void;
|
||||
@@ -75,6 +77,7 @@ export function MedicineLotFields({
|
||||
doseQuantity,
|
||||
productError,
|
||||
doseError,
|
||||
doseNotice,
|
||||
filled,
|
||||
onProductChange,
|
||||
onMedicineChange,
|
||||
@@ -148,6 +151,7 @@ export function MedicineLotFields({
|
||||
hasLot={medicineLotId !== ""}
|
||||
value={doseQuantity}
|
||||
error={doseError}
|
||||
notice={doseNotice}
|
||||
isFromCatalogue={filled.doseQuantity}
|
||||
onChange={onDoseChange}
|
||||
/>
|
||||
@@ -226,6 +230,7 @@ function DoseField({
|
||||
hasLot,
|
||||
value,
|
||||
error,
|
||||
notice,
|
||||
isFromCatalogue,
|
||||
onChange,
|
||||
}: {
|
||||
@@ -234,6 +239,7 @@ function DoseField({
|
||||
hasLot: boolean;
|
||||
value: string;
|
||||
error: string | undefined;
|
||||
notice: string | undefined;
|
||||
isFromCatalogue: boolean | undefined;
|
||||
onChange: (dose: string) => void;
|
||||
}) {
|
||||
@@ -242,7 +248,12 @@ function DoseField({
|
||||
? "Je Volk. Ohne Dosis wird nichts abgebucht."
|
||||
: "Je Volk, wie auf der Packungsbeilage.";
|
||||
return (
|
||||
<Field label="Dosis je Volk" id={`${idPrefix}-dose`} hint={hint} error={error}>
|
||||
<Field
|
||||
label="Dosis je Volk"
|
||||
id={`${idPrefix}-dose`}
|
||||
hint={hint}
|
||||
error={error ?? doseFormatError(value)}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -260,6 +271,11 @@ function DoseField({
|
||||
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
|
||||
</div>
|
||||
<CatalogueNote id={id} shown={isFromCatalogue} />
|
||||
<div role="status" aria-live="polite" className="empty:hidden">
|
||||
{notice ? (
|
||||
<p className="rounded-lg bg-warning/20 px-3 py-2 text-sm">{notice}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
Reference in new issue
Block a user