feat(t5): Auswahlgruppe mit Haken, Karten-Variante, Plus/Minus (T5a A6)

- ChoiceGroup nach Spec 10.2: gewählt mit Haken und 2-px-Tintenrahmen,
  „Nicht geprüft“ gestrichelt; neue Variante appearance="cards" mit Icon
  und Beschreibung (Winter-Ergebnis, Darstellung, Behördenmeldung).
- Eigenbau-Radios in Behördenmeldung, Phänologie-Bericht und
  Zeilen-Ausrichtung durch ChoiceGroup ersetzt.
- Button pressed → aria-pressed (Fett/Kursiv, „Aufbau als Liste“);
  Druck-Versatz nur mit motion-full.
- NumberStepper (ganze Schritte) für Winter-Waben, Milbenzahl (neu und
  bearbeiten), Weiselzellen, Entnahme/Zählbestand, Startposition; Entnahme
  nur 1 bis Restmenge, Startposition höchstens 1000.
- Baukasten-Wächter: Radios nur in ChoiceGroup, aria-pressed nur in
  Button und Kalender, auch in Objektform und .ts-Dateien.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:44:31 +02:00
1 parent 6ac9eef93b
commit 208e9baaf0
30 files changed
+809 -198

No files matched your search

@@ -281,4 +281,30 @@ describe("PrintLabelsCard", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
});
it("label print start position uses NumberStepper", async () => {
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
const input = screen.getByLabelText(/Startposition/);
expect(input).toHaveValue("1");
expect(input).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
expect(input).toHaveValue("3");
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
await waitFor(() =>
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 3, proof: false }),
);
});
it("caps the start position at the server limit of 1000 when the template has no sheet size", async () => {
vi.mocked(fetchLabelTemplates).mockResolvedValue([{ ...TEMPLATE, columns: null, rows: null }]);
renderCard();
await screen.findByRole("button", { name: "Etiketten als PDF" });
const input = screen.getByLabelText(/Startposition/);
await userEvent.clear(input);
await userEvent.type(input, "5000");
expect(input).toHaveValue("1000");
});
});
@@ -17,10 +17,13 @@ import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberStepper } from "@/components/form/number-stepper";
import { RecordSelect } from "@/components/form/record-select";
/** Highest start position the server takes (label-templates.controller.ts). */
const MAX_START_POSITION = 1000;
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
@@ -138,8 +141,10 @@ export function PrintLabelsCard({
const canCheck = canReadTemplates && hasPermission("bottling:read");
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
const reasonId = useId();
const startHintId = useId();
const [templateId, setTemplateId] = useState("");
const [startPosition, setStartPosition] = useState(1);
// "" while the field is emptied: printing then starts at the first label.
const [startPositionText, setStartPositionText] = useState("1");
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
const [printError, setPrintError] = useState<string | null>(null);
const [printDone, setPrintDone] = useState<string | null>(null);
@@ -160,6 +165,7 @@ export function PrintLabelsCard({
selectedTemplate?.columns && selectedTemplate?.rows
? selectedTemplate.columns * selectedTemplate.rows
: undefined;
const startPosition = Math.max(1, Number(startPositionText) || 1);
// The answer holds the food business address: no persistence, dropped as
// soon as nobody looks at it any more.
@@ -314,16 +320,18 @@ export function PrintLabelsCard({
<Label htmlFor="print-start-position">
Startposition auf dem Bogen (1 = oben links)
</Label>
<Input
<NumberStepper
id="print-start-position"
type="number"
inputMode="numeric"
describedBy={startHintId}
value={startPositionText}
onChange={setStartPositionText}
min={1}
max={slotsPerSheet}
value={startPosition}
onChange={(event) => setStartPosition(Math.max(1, Number(event.target.value)))}
max={slotsPerSheet ?? MAX_START_POSITION}
unitLabel={slotsPerSheet ? `von ${slotsPerSheet}` : "Position"}
decrementLabel="Eine Position zurück"
incrementLabel="Eine Position weiter"
/>
<p className="text-xs text-muted-foreground">
<p id={startHintId} className="text-xs text-muted-foreground">
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
</p>
</div>
@@ -55,4 +55,20 @@ describe("SwarmControlEditDialog", () => {
notes: null,
});
});
it("swarm control edit counts queen cells with NumberStepper", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<SwarmControlEditDialog check={check} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await screen.findByRole("dialog");
expect(screen.getByLabelText(/^Weiselzellen gefunden/)).toHaveValue("3");
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle weniger/ }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSwarmControlCheck).mock.calls[0][1]).toMatchObject({ queenCellsFound: 2 });
});
});
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import {
@@ -115,15 +116,14 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
<FormSection step={2} title="Befund">
<Field label="Weiselzellen gefunden" hint="Anzahl">
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
aria-describedby={describedBy}
describedBy={describedBy}
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
onChange={setQueenCellsFound}
unitLabel="Weiselzellen"
decrementLabel="Eine Weiselzelle weniger"
incrementLabel="Eine Weiselzelle mehr"
/>
)}
</Field>
@@ -135,4 +135,15 @@ describe("VarroaEditDialog", () => {
await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
});
it("the varroa edit dialog counts mites with NumberStepper", async () => {
await openDialog();
const miteCount = screen.getByLabelText(/^Milbenzahl/);
expect(miteCount).toHaveValue("12");
expect(miteCount).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ miteCount: 13 });
});
});
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormSection } from "@/components/form/form-section";
import {
Dialog,
@@ -146,17 +147,18 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
{isNaturalFall ? (
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
) : null}
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}>
{/* One column: the stepper needs the width of the dialog on a phone. */}
<div className="flex flex-col gap-3">
<Field label="Milbenzahl">
{({ id }) => (
<Input
{({ id, describedBy }) => (
<NumberStepper
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
describedBy={describedBy}
value={miteCount}
onChange={(event) => setMiteCount(event.target.value)}
onChange={setMiteCount}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/>
)}
</Field>
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateNoticeDialog } from "./create-notice-dialog";
@@ -97,4 +97,14 @@ describe("CreateNoticeDialog", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
});
it("offers the kind of notice as cards with their hint", async () => {
renderDialog();
await open();
const group = screen.getByRole("radiogroup", { name: "Art der Anzeige" });
const first = within(group).getByRole("radio", { name: "Erstanzeige" });
expect(first).toHaveAttribute("aria-checked", "true");
expect(first).toHaveAccessibleDescription("Deine erste Meldung an das Veterinäramt.");
expect(first).toHaveClass("min-h-14");
});
});
@@ -1,11 +1,12 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { FormEvent, useEffect, useId, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,12 @@ const KIND_HINT: Record<AuthorityNoticeKind, string> = {
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
};
const KIND_OPTIONS: readonly ChoiceOption<AuthorityNoticeKind>[] = NOTICE_KINDS.map((kind) => ({
value: kind,
label: NOTICE_KIND_LABEL[kind],
description: KIND_HINT[kind],
}));
interface CreateNoticeDialogProps {
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
@@ -75,6 +82,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
const queryClient = useQueryClient();
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const kindLabelId = useId();
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
const [year, setYear] = useState(String(new Date().getFullYear()));
const [alreadySent, setAlreadySent] = useState(false);
@@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
{NOTICE_KINDS.map((entry) => (
<label
key={entry}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-foreground has-[:checked]:bg-primary/10"
>
<input
type="radio"
name="notice-kind"
value={entry}
checked={kind === entry}
onChange={() => setKind(entry)}
aria-labelledby={`notice-kind-${entry}`}
aria-describedby={`notice-kind-hint-${entry}`}
className="mt-1 size-4"
/>
<span className="flex flex-col">
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
{NOTICE_KIND_LABEL[entry]}
</span>
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
{KIND_HINT[entry]}
</span>
</span>
</label>
))}
</fieldset>
<div className="flex flex-col gap-2">
<p id={kindLabelId} className="text-base font-medium">
Art der Anzeige
</p>
<ChoiceGroup
options={KIND_OPTIONS}
value={kind}
onChange={setKind}
labelledBy={kindLabelId}
appearance="cards"
/>
</div>
{kind === "TSK_REPORT" ? (
<div className="flex flex-col gap-2">
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
import { AppearanceSection } from "./appearance-section";
@@ -54,4 +54,18 @@ describe("AppearanceSection", () => {
expect(document.documentElement.dataset.motion).toBe("subtle");
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
});
it("offers colour scheme, contrast and motion as cards with a description each", () => {
render(<AppearanceSection />);
for (const group of ["Farbschema", "Kontrast", "Bewegung & Effekte"]) {
for (const radio of within(screen.getByRole("radiogroup", { name: group })).getAllByRole("radio")) {
expect(radio).toHaveClass("min-h-14");
expect(radio).toHaveAccessibleDescription(/\S/);
}
}
expect(screen.getByRole("radio", { name: "Hell" })).toHaveAccessibleDescription("Am besten bei Tageslicht.");
expect(screen.getByRole("radio", { name: "Dezent" })).toHaveAccessibleDescription(
"Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
);
});
});
@@ -6,17 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [
{ value: "light", label: "Hell – am besten bei Tageslicht" },
{ value: "dark", label: "Dunkel – für abends" },
{ value: "system", label: "Wie Gerät" },
{ value: "light", label: "Hell", description: "Am besten bei Tageslicht." },
{ value: "dark", label: "Dunkel", description: "Für abends." },
{ value: "system", label: "Wie Gerät", description: "Folgt der Einstellung des Geräts." },
];
const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [
{ value: "normal", label: "Normal" },
{ value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" },
{ value: "normal", label: "Normal", description: "Für drinnen und Schatten." },
{ value: "sun", label: "Sonne", description: "Maximaler Kontrast, größere Schrift, für draußen." },
];
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
@@ -28,11 +26,12 @@ const MOTION_DESCRIPTIONS: Record<MotionLevel, string> = {
const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({
value,
label: MOTION_LABELS[value],
description: MOTION_DESCRIPTIONS[value],
}));
export function AppearanceSection() {
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
const ids = { theme: useId(), contrast: useId(), motion: useId() };
return (
<Card>
@@ -53,7 +52,7 @@ export function AppearanceSection() {
value={theme}
onChange={setTheme}
labelledBy={ids.theme}
optionClassName={OPTION_CLASS}
appearance="cards"
/>
</div>
<div className="flex flex-col gap-2">
@@ -65,7 +64,7 @@ export function AppearanceSection() {
value={contrast}
onChange={setContrast}
labelledBy={ids.contrast}
optionClassName={OPTION_CLASS}
appearance="cards"
/>
</div>
<div className="flex flex-col gap-2">
@@ -77,13 +76,8 @@ export function AppearanceSection() {
value={motion}
onChange={setMotion}
labelledBy={ids.motion}
describedBy={ids.motionHint}
className="grid-cols-3"
optionClassName="px-3 py-2"
appearance="cards"
/>
<p id={ids.motionHint} className="text-sm text-muted-foreground">
{MOTION_DESCRIPTIONS[motion]}
</p>
</div>
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
</CardContent>
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { LabelLineElement, LabelTextElement } from "@/lib/label-templates/types";
import { ElementPropertiesPanel } from "./element-properties-panel";
const LINE: LabelLineElement = {
id: "l-1",
type: "line",
x: 2,
y: 3,
length: 40,
thickness: 1,
color: "#000000",
orientation: "horizontal",
};
const TEXT: LabelTextElement = {
id: "t-1",
type: "text",
x: 2,
y: 3,
width: 40,
height: 8,
text: "Honig",
fontSize: 10,
fontFamily: "sans",
bold: true,
italic: false,
color: "#000000",
align: "left",
};
function renderPanel(element: LabelLineElement | LabelTextElement, onChange = vi.fn()) {
render(
<ElementPropertiesPanel element={element} onChange={onChange} onDelete={vi.fn()} onDuplicate={vi.fn()} />,
);
return onChange;
}
describe("ElementPropertiesPanel", () => {
it("the label designer line alignment is a ChoiceGroup", async () => {
const onChange = renderPanel(LINE);
const group = screen.getByRole("radiogroup", { name: "Ausrichtung der Linie" });
expect(group).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Waagerecht" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "Senkrecht" })).toHaveAttribute("aria-checked", "false");
await userEvent.click(screen.getByRole("radio", { name: "Senkrecht" }));
expect(onChange).toHaveBeenCalledWith({ orientation: "vertical" });
});
it("bold and italic are named toolbar toggles with aria-pressed", async () => {
const onChange = renderPanel(TEXT);
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Kursiv" })).toHaveAttribute("aria-pressed", "false");
await userEvent.click(screen.getByRole("button", { name: "Kursiv" }));
expect(onChange).toHaveBeenCalledWith({ italic: true });
});
});
@@ -1,9 +1,11 @@
"use client";
import { useId } from "react";
import { Bold, Copy, Italic, Trash2 } from "lucide-react";
import {
LabelElement,
LabelFontFamily,
LabelLineOrientation,
LabelTextAlign,
PLACEHOLDER_KEYS,
PLACEHOLDER_LABELS,
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EnumSelect } from "@/components/form/enum-select";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = {
sans: "Serifenlos",
@@ -25,6 +28,11 @@ const ALIGN_LABELS: Record<LabelTextAlign, string> = {
right: "Rechts",
};
const ORIENTATION_OPTIONS = choiceOptions<LabelLineOrientation>({
horizontal: "Waagerecht",
vertical: "Senkrecht",
});
interface ElementPropertiesPanelProps {
element: LabelElement;
onChange: (patch: Partial<LabelElement>) => void;
@@ -208,17 +216,21 @@ function TextFields({
type="button"
variant={element.bold ? "default" : "outline"}
size="icon-sm"
pressed={element.bold}
aria-label="Fett"
onClick={() => onChange({ bold: !element.bold })}
>
<Bold className="size-3.5" />
<Bold aria-hidden className="size-3.5" />
</Button>
<Button
type="button"
variant={element.italic ? "default" : "outline"}
size="icon-sm"
pressed={element.italic}
aria-label="Kursiv"
onClick={() => onChange({ italic: !element.italic })}
>
<Italic className="size-3.5" />
<Italic aria-hidden className="size-3.5" />
</Button>
<EnumSelect
id="element-text-align"
@@ -240,6 +252,7 @@ function LineFields({
element: Extract<LabelElement, { type: "line" }>;
onChange: (patch: Partial<LabelElement>) => void;
}) {
const orientationLabelId = useId();
return (
<>
<div className="flex gap-3">
@@ -274,25 +287,17 @@ function LineFields({
onChange={(event) => onChange({ color: event.target.value })}
/>
</div>
<div className="flex gap-1.5">
<Button
type="button"
variant={element.orientation === "horizontal" ? "default" : "outline"}
size="sm"
className="flex-1"
onClick={() => onChange({ orientation: "horizontal" })}
>
Waagerecht
</Button>
<Button
type="button"
variant={element.orientation === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1"
onClick={() => onChange({ orientation: "vertical" })}
>
Senkrecht
</Button>
<div className="flex flex-col gap-2">
<p id={orientationLabelId} className="text-sm leading-none font-medium">
Ausrichtung der Linie
</p>
<ChoiceGroup
options={ORIENTATION_OPTIONS}
value={element.orientation}
onChange={(orientation) => onChange({ orientation })}
labelledBy={orientationLabelId}
className="grid-cols-2"
/>
</div>
</>
);
@@ -90,4 +90,17 @@ describe("NewSwarmControlCheckPage", () => {
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createSwarmControlCheck).not.toHaveBeenCalled();
});
it("swarm control new counts queen cells with NumberStepper", async () => {
vi.mocked(createSwarmControlCheck).mockResolvedValue({ id: "s-1", colonyId: "colony-1" } as never);
renderPage();
const cells = screen.getByLabelText(/Weiselzellen gefunden/);
expect(cells).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
expect(cells).toHaveValue("2");
await userEvent.click(screen.getByRole("button", { name: "Kontrolle speichern" }));
expect(vi.mocked(createSwarmControlCheck).mock.calls[0][0]).toMatchObject({ queenCellsFound: 2 });
});
});
@@ -20,6 +20,7 @@ import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { FormSection } from "@/components/form/form-section";
@@ -174,17 +175,14 @@ export default function NewSwarmControlCheckPage() {
hint="Zellen, in denen eine neue Königin heranwächst – groß, zapfenförmig, meist am Wabenrand."
>
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
describedBy={describedBy}
value={values.queenCellsFound}
onChange={(event) =>
set("queenCellsFound", event.target.value)
}
onChange={(count) => set("queenCellsFound", count)}
unitLabel="Weiselzellen"
decrementLabel="Eine Weiselzelle weniger"
incrementLabel="Eine Weiselzelle mehr"
/>
)}
</Field>
@@ -131,6 +131,15 @@ describe("NewVarroaMeasurementPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Messung speichern" }));
}
it("counts the mites of one colony with NumberStepper", async () => {
renderPage();
const miteCount = screen.getByLabelText(/^Milbenzahl/);
expect(miteCount).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(miteCount).toHaveValue("1");
});
it("preselects no period chip", () => {
renderPage();
for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) {
@@ -34,6 +34,7 @@ import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
import { CaptureColonyField } from "@/components/form/capture-colony-field";
@@ -332,15 +333,14 @@ export default function NewVarroaMeasurementPage() {
}
>
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
describedBy={describedBy}
value={values.miteCount}
onChange={(event) => set("miteCount", event.target.value)}
onChange={(count) => set("miteCount", count)}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/>
)}
</Field>
@@ -107,7 +107,7 @@ describe("WinterCheckDialog", () => {
it("records a lost colony with its loss cause", async () => {
renderDialog({ season: "SPRING" });
await userEvent.click(screen.getByRole("button", { name: "Verloren" }));
await userEvent.click(screen.getByRole("radio", { name: "Verloren" }));
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
const cause = screen.getByRole("combobox", { name: "Verlustursache" });
expect(cause).toHaveTextContent("Ursache wählen");
@@ -130,4 +130,32 @@ describe("WinterCheckDialog", () => {
const [input] = vi.mocked(createWinterCheck).mock.calls[0];
expect(input).toHaveProperty("queenPresent", null);
});
it("winter seams use NumberStepper", async () => {
renderDialog({ existing });
const seams = screen.getByLabelText("Besetzte Wabengassen");
expect(seams).toHaveValue("7");
expect(seams).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine besetzte Wabengasse mehr/ }));
expect(seams).toHaveValue("8");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateWinterCheck).mock.calls[0];
expect(input).toHaveProperty("occupiedSeams", 8);
});
it("asks for the outcome with one card per answer, each with its icon", async () => {
renderDialog({ season: "SPRING" });
const group = screen.getByRole("radiogroup", { name: "Wie ist das Volk durch den Winter gekommen?" });
const radios = within(group).getAllByRole("radio");
expect(radios.map((radio) => radio.getAttribute("aria-checked"))).toEqual(["false", "false", "false"]);
for (const radio of radios) {
expect(radio).toHaveClass("min-h-14");
expect(radio.querySelector("svg")).not.toBeNull();
}
await userEvent.click(within(group).getByRole("radio", { name: "Schwach" }));
expect(within(group).getByRole("radio", { name: "Schwach" })).toHaveAttribute("aria-checked", "true");
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
});
});
@@ -1,7 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import { Check } from "lucide-react";
import { FormEvent, useId, useState } from "react";
import { CircleX, Sprout, TriangleAlert } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
@@ -22,9 +22,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { TriStateField } from "@/components/form/tri-state-field";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { EnumSelect } from "@/components/form/enum-select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
@@ -42,7 +43,30 @@ function parseDecimal(value: string): number {
return Number(value.replace(",", "."));
}
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"];
/** Highest count the server takes (create-winter-check.dto.ts). */
const MAX_SEAMS = 60;
// Icon plus word, so the answer never hangs on colour (spec 10.2).
const OUTCOME_OPTIONS: readonly ChoiceOption<WinterOutcome | null>[] = [
{
value: "SURVIVED",
label: WINTER_OUTCOME_LABELS.SURVIVED,
description: "Das Volk lebt und ist gut durchgekommen.",
icon: Sprout,
},
{
value: "WEAK",
label: WINTER_OUTCOME_LABELS.WEAK,
description: "Das Volk lebt, ist aber klein geworden.",
icon: TriangleAlert,
},
{
value: "LOST",
label: WINTER_OUTCOME_LABELS.LOST,
description: "Das Volk hat den Winter nicht überlebt.",
icon: CircleX,
},
];
export function WinterCheckDialog({
target,
@@ -67,6 +91,7 @@ export function WinterCheckDialog({
const [markDeceased, setMarkDeceased] = useState(target.isLiving);
const [notes, setNotes] = useState(existing?.notes ?? "");
const [idempotencyKey] = useState(newIdempotencyKey);
const outcomeLabelId = useId();
const isLost = outcome === "LOST";
const missing = isSpring && (!outcome || (isLost && !lossCause));
@@ -79,7 +104,7 @@ export function WinterCheckDialog({
winterYear: target.winterYear,
date: toApiDate(date),
// Empty means null, not omitted: on an edit it clears an earlier entry.
occupiedSeams: emptyToNull(occupiedSeams, parseDecimal),
occupiedSeams: emptyToNull(occupiedSeams, Number),
// Spring checks have no feed stock field - leave whatever is stored.
feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal),
queenPresent,
@@ -123,24 +148,18 @@ export function WinterCheckDialog({
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{isSpring ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
<div className="grid grid-cols-3 gap-2">
{OUTCOMES.map((value) => (
<Button
key={value}
type="button"
size="touch"
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
aria-pressed={outcome === value}
onClick={() => setOutcome(value)}
>
{outcome === value ? <Check aria-hidden /> : null}
{WINTER_OUTCOME_LABELS[value]}
</Button>
))}
</div>
</fieldset>
<div className="flex flex-col gap-2">
<p id={outcomeLabelId} className="text-base font-medium">
Wie ist das Volk durch den Winter gekommen?
</p>
<ChoiceGroup
options={OUTCOME_OPTIONS}
value={outcome}
onChange={setOutcome}
labelledBy={outcomeLabelId}
appearance="cards"
/>
</div>
) : null}
{isLost ? (
@@ -184,16 +203,18 @@ export function WinterCheckDialog({
</div>
{!isLost ? (
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
<Input
<NumberStepper
id="winter-seams"
inputMode="numeric"
className="h-11"
aria-describedby="winter-seams-hint"
describedBy="winter-seams-hint"
value={occupiedSeams}
onChange={(event) => setOccupiedSeams(event.target.value)}
onChange={setOccupiedSeams}
max={MAX_SEAMS}
unitLabel="Gassen"
decrementLabel="Eine besetzte Wabengasse weniger"
incrementLabel="Eine besetzte Wabengasse mehr"
/>
<p id="winter-seams-hint" className="text-xs text-muted-foreground">
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres