feat(ui): calmer inspection form with sections, tap ratings and drafts
AuDHD-oriented rework of the most-used form, plus shared building blocks
for the remaining forms:
- Field: label, "Pflicht"/"optional", hint and the error right at the
field instead of a vanishing toast.
- FormSection: numbered steps; optional parts start folded and say how
many fields they hold.
- RatingScale (1-5 as five 44 px buttons with both ends named),
ToggleChip (yes/no tiles with tick and colour), StickySubmit (save
stays in thumb reach on phones).
- useFormDraft + DraftBanner: an unfinished entry is kept on the device
and offered back explicitly ("Weitermachen" / "Verwerfen"), never
applied silently; drafts expire after 3 days.
Inspection form: 21 flat fields became 5 steps, only colony and date are
required, observations are single taps, a hint appears when neither queen
nor eggs were seen, server messages show inline.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8b6f48b996
commit
32dfd8d8a7
11 files changed
+1006
-311
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import type { createInspection } from "@/lib/inspections/api";
|
||||
|
||||
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
|
||||
|
||||
export const FEED_STATUS_OPTIONS: { value: FeedStatus; label: string }[] = [
|
||||
{ value: "ausreichend", label: "Ausreichend" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "kritisch", label: "Kritisch" },
|
||||
];
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface InspectionFormValues {
|
||||
colonyId: string;
|
||||
date: string;
|
||||
weather: string;
|
||||
temperature: string;
|
||||
colonyStrength: string;
|
||||
broodAmount: string;
|
||||
broodPattern: number | null;
|
||||
openBroodPresent: boolean;
|
||||
cappedBroodPresent: boolean;
|
||||
queenSeen: boolean;
|
||||
eggsPresent: boolean;
|
||||
queenCellsPresent: boolean;
|
||||
swarmMood: number | null;
|
||||
gentleness: number | null;
|
||||
combBehavior: number | null;
|
||||
feedStatus: FeedStatus;
|
||||
honeySuperStatus: string;
|
||||
healthIssues: string;
|
||||
varroaIssues: string;
|
||||
measures: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function initialInspectionValues(
|
||||
colonyId: string,
|
||||
): InspectionFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
date: todayIsoDate(),
|
||||
weather: "",
|
||||
temperature: "",
|
||||
colonyStrength: "",
|
||||
broodAmount: "",
|
||||
broodPattern: null,
|
||||
openBroodPresent: false,
|
||||
cappedBroodPresent: false,
|
||||
queenSeen: false,
|
||||
eggsPresent: true,
|
||||
queenCellsPresent: false,
|
||||
swarmMood: null,
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: "ausreichend",
|
||||
honeySuperStatus: "",
|
||||
healthIssues: "",
|
||||
varroaIssues: "",
|
||||
measures: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
const numberOrUndefined = (value: string) =>
|
||||
value.trim() ? Number(value) : undefined;
|
||||
|
||||
export function toInspectionInput(
|
||||
values: InspectionFormValues,
|
||||
): Parameters<typeof createInspection>[0] {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: new Date(values.date).toISOString(),
|
||||
weather: textOrUndefined(values.weather),
|
||||
temperature: numberOrUndefined(values.temperature),
|
||||
colonyStrength: numberOrUndefined(values.colonyStrength),
|
||||
broodAmount: textOrUndefined(values.broodAmount),
|
||||
broodPattern: values.broodPattern ?? undefined,
|
||||
openBroodPresent: values.openBroodPresent,
|
||||
cappedBroodPresent: values.cappedBroodPresent,
|
||||
queenSeen: values.queenSeen,
|
||||
eggsPresent: values.eggsPresent,
|
||||
queenCellsPresent: values.queenCellsPresent,
|
||||
swarmMood: values.swarmMood ?? undefined,
|
||||
gentleness: values.gentleness ?? undefined,
|
||||
combBehavior: values.combBehavior ?? undefined,
|
||||
feedStatus: values.feedStatus,
|
||||
honeySuperStatus: textOrUndefined(values.honeySuperStatus),
|
||||
healthIssues: textOrUndefined(values.healthIssues),
|
||||
varroaIssues: textOrUndefined(values.varroaIssues),
|
||||
measures: textOrUndefined(values.measures),
|
||||
notes: textOrUndefined(values.notes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewInspectionPage from "./page";
|
||||
import { createInspection } from "@/lib/inspections/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewInspectionPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewInspectionPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(fetchColonies).mockResolvedValue([
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createInspection).mockReset();
|
||||
});
|
||||
|
||||
it("marks what is required and keeps the extra fields folded", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
|
||||
expect(screen.queryByLabelText(/Wetter/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("records ratings and observations with single taps", async () => {
|
||||
vi.mocked(createInspection).mockResolvedValue({
|
||||
id: "i-1",
|
||||
colonyId: "colony-1",
|
||||
} as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Königin gesehen" }),
|
||||
);
|
||||
// Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter.
|
||||
const gentleness = screen.getAllByRole("radiogroup")[1];
|
||||
await userEvent.click(
|
||||
gentleness.querySelector('[aria-label="4 von 5"]') as HTMLElement,
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Durchsicht speichern" }),
|
||||
);
|
||||
|
||||
const [input] = vi.mocked(createInspection).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
colonyId: "colony-1",
|
||||
queenSeen: true,
|
||||
gentleness: 4,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("warns when neither queen nor eggs were seen", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Stifte vorhanden" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Weder Königin noch Stifte gesehen/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,16 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import { AlertTriangle, ClipboardCheck } from "lucide-react";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createInspection } from "@/lib/inspections/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { queueInspection } from "@/lib/offline/db";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -23,52 +24,48 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const FEED_STATUS_LABELS = {
|
||||
ausreichend: "Ausreichend",
|
||||
niedrig: "Niedrig",
|
||||
kritisch: "Kritisch",
|
||||
} as const;
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { RatingScale } from "@/components/form/rating-scale";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FEED_STATUS_OPTIONS,
|
||||
InspectionFormValues,
|
||||
initialInspectionValues,
|
||||
toInspectionInput,
|
||||
} from "./inspection-form-values";
|
||||
|
||||
export default function NewInspectionPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: colonies } = useQuery({
|
||||
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
const activeColonies =
|
||||
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
|
||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
||||
const [date, setDate] = useState(todayIsoDate());
|
||||
const [weather, setWeather] = useState("");
|
||||
const [temperature, setTemperature] = useState("");
|
||||
const [colonyStrength, setColonyStrength] = useState("");
|
||||
const [broodAmount, setBroodAmount] = useState("");
|
||||
const [broodPattern, setBroodPattern] = useState("");
|
||||
const [openBroodPresent, setOpenBroodPresent] = useState(false);
|
||||
const [cappedBroodPresent, setCappedBroodPresent] = useState(false);
|
||||
const [queenSeen, setQueenSeen] = useState(false);
|
||||
const [eggsPresent, setEggsPresent] = useState(true);
|
||||
const [queenCellsPresent, setQueenCellsPresent] = useState(false);
|
||||
const [swarmMood, setSwarmMood] = useState("");
|
||||
const [gentleness, setGentleness] = useState("");
|
||||
const [combBehavior, setCombBehavior] = useState("");
|
||||
const [feedStatus, setFeedStatus] = useState<"ausreichend" | "niedrig" | "kritisch">(
|
||||
"ausreichend",
|
||||
const [values, setValues] = useState<InspectionFormValues>(() =>
|
||||
initialInspectionValues(searchParams.get("colonyId") ?? ""),
|
||||
);
|
||||
const [honeySuperStatus, setHoneySuperStatus] = useState("");
|
||||
const [healthIssues, setHealthIssues] = useState("");
|
||||
const [varroaIssues, setVarroaIssues] = useState("");
|
||||
const [measures, setMeasures] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [colonyError, setColonyError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("inspection-new", values, dirty);
|
||||
|
||||
function set<K extends keyof InspectionFormValues>(
|
||||
key: K,
|
||||
value: InspectionFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
if (key === "colonyId") setColonyError(null);
|
||||
}
|
||||
|
||||
// One key per opened form: a double tap or an offline retry of the
|
||||
// same entry is recognised by the server instead of saved twice.
|
||||
@@ -77,6 +74,7 @@ export default function NewInspectionPage() {
|
||||
mutationFn: (input: Parameters<typeof createInspection>[0]) =>
|
||||
createInspection(input, { idempotencyKey }),
|
||||
onSuccess: (inspection) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
||||
});
|
||||
@@ -85,11 +83,19 @@ export default function NewInspectionPage() {
|
||||
},
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
setError("Durchsicht konnte nicht gespeichert werden.");
|
||||
setSubmitError(
|
||||
errorMessage(
|
||||
mutationError,
|
||||
"Durchsicht konnte nicht gespeichert werden.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void queueInspection(variables, idempotencyKey).then(() => {
|
||||
toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.");
|
||||
draft.clear();
|
||||
toast.info(
|
||||
"Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
router.push(`/colonies/${variables.colonyId}`);
|
||||
});
|
||||
},
|
||||
@@ -97,294 +103,360 @@ export default function NewInspectionPage() {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!colonyId) {
|
||||
setError("Bitte ein Volk auswählen.");
|
||||
setSubmitError(null);
|
||||
if (!values.colonyId) {
|
||||
setColonyError("Bitte ein Volk auswählen.");
|
||||
document.getElementById("inspection-colony")?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
weather: weather || undefined,
|
||||
temperature: temperature ? Number(temperature) : undefined,
|
||||
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
|
||||
broodAmount: broodAmount || undefined,
|
||||
broodPattern: broodPattern ? Number(broodPattern) : undefined,
|
||||
openBroodPresent,
|
||||
cappedBroodPresent,
|
||||
queenSeen,
|
||||
eggsPresent,
|
||||
queenCellsPresent,
|
||||
swarmMood: swarmMood ? Number(swarmMood) : undefined,
|
||||
gentleness: gentleness ? Number(gentleness) : undefined,
|
||||
combBehavior: combBehavior ? Number(combBehavior) : undefined,
|
||||
feedStatus,
|
||||
honeySuperStatus: honeySuperStatus || undefined,
|
||||
healthIssues: healthIssues || undefined,
|
||||
varroaIssues: varroaIssues || undefined,
|
||||
measures: measures || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
mutation.mutate(toInspectionInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
const noQueenSigns = !values.queenSeen && !values.eggsPresent;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={ClipboardCheck}
|
||||
title="Neue Durchsicht"
|
||||
description="Dokumentiere den Zustand eines Volkes."
|
||||
description="Nur Volk und Datum sind Pflicht – dazu, was du gesehen hast."
|
||||
gradient="var(--gradient-sage)"
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-colony">Volk</Label>
|
||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||
<SelectTrigger id="inspection-colony" className="h-11 w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeColonies.map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-date">Datum</Label>
|
||||
<Input
|
||||
id="inspection-date"
|
||||
type="date"
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<Field
|
||||
label="Volk"
|
||||
required
|
||||
className="h-11"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-weather">Wetter</Label>
|
||||
<Input
|
||||
id="inspection-weather"
|
||||
className="h-11"
|
||||
value={weather}
|
||||
onChange={(event) => setWeather(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-temperature">Temperatur (°C)</Label>
|
||||
<Input
|
||||
id="inspection-temperature"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="h-11"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-strength">Volksstärke (Waben)</Label>
|
||||
<Input
|
||||
id="inspection-strength"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="h-11"
|
||||
value={colonyStrength}
|
||||
onChange={(event) => setColonyStrength(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-brood-amount">Brutmenge</Label>
|
||||
<Input
|
||||
id="inspection-brood-amount"
|
||||
className="h-11"
|
||||
value={broodAmount}
|
||||
onChange={(event) => setBroodAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-brood-pattern">Brutbild (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-brood-pattern"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
className="h-11"
|
||||
value={broodPattern}
|
||||
onChange={(event) => setBroodPattern(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor="inspection-open-brood"
|
||||
>
|
||||
<input
|
||||
id="inspection-open-brood"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={openBroodPresent}
|
||||
onChange={(event) => setOpenBroodPresent(event.target.checked)}
|
||||
/>
|
||||
Offene Brut vorhanden
|
||||
</label>
|
||||
<label
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor="inspection-capped-brood"
|
||||
>
|
||||
<input
|
||||
id="inspection-capped-brood"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={cappedBroodPresent}
|
||||
onChange={(event) => setCappedBroodPresent(event.target.checked)}
|
||||
/>
|
||||
Verdeckelte Brut vorhanden
|
||||
</label>
|
||||
<label className="flex h-11 items-center gap-3 text-sm" htmlFor="inspection-queen-seen">
|
||||
<input
|
||||
id="inspection-queen-seen"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={queenSeen}
|
||||
onChange={(event) => setQueenSeen(event.target.checked)}
|
||||
/>
|
||||
Königin gesehen
|
||||
</label>
|
||||
<label
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor="inspection-eggs-present"
|
||||
>
|
||||
<input
|
||||
id="inspection-eggs-present"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={eggsPresent}
|
||||
onChange={(event) => setEggsPresent(event.target.checked)}
|
||||
/>
|
||||
Stifte vorhanden
|
||||
</label>
|
||||
<label
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor="inspection-queen-cells"
|
||||
>
|
||||
<input
|
||||
id="inspection-queen-cells"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={queenCellsPresent}
|
||||
onChange={(event) => setQueenCellsPresent(event.target.checked)}
|
||||
/>
|
||||
Weiselzellen vorhanden
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-swarm-mood">Schwarmstimmung (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-swarm-mood"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
className="h-11"
|
||||
value={swarmMood}
|
||||
onChange={(event) => setSwarmMood(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-gentleness">Sanftmut (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-gentleness"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
className="h-11"
|
||||
value={gentleness}
|
||||
onChange={(event) => setGentleness(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-comb-behavior">Wabensitz (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-comb-behavior"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
className="h-11"
|
||||
value={combBehavior}
|
||||
onChange={(event) => setCombBehavior(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-feed-status">Futterstatus</Label>
|
||||
<Select
|
||||
value={feedStatus}
|
||||
onValueChange={(value) =>
|
||||
setFeedStatus((value ?? "ausreichend") as typeof feedStatus)
|
||||
error={colonyError}
|
||||
hint={
|
||||
!coloniesLoading && activeColonies.length === 0
|
||||
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="inspection-feed-status" className="h-11 w-full">
|
||||
<SelectValue>{FEED_STATUS_LABELS[feedStatus]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
||||
<SelectItem value="niedrig">Niedrig</SelectItem>
|
||||
<SelectItem value="kritisch">Kritisch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-honey-super">Honigraumstatus</Label>
|
||||
<Input
|
||||
id="inspection-honey-super"
|
||||
className="h-11"
|
||||
value={honeySuperStatus}
|
||||
onChange={(event) => setHoneySuperStatus(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-varroa">Varroa-Befund</Label>
|
||||
<Input
|
||||
id="inspection-varroa"
|
||||
className="h-11"
|
||||
value={varroaIssues}
|
||||
onChange={(event) => setVarroaIssues(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-health">Gesundheitsauffälligkeiten</Label>
|
||||
<Input
|
||||
id="inspection-health"
|
||||
className="h-11"
|
||||
value={healthIssues}
|
||||
onChange={(event) => setHealthIssues(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-measures">Maßnahmen</Label>
|
||||
<Textarea
|
||||
id="inspection-measures"
|
||||
value={measures}
|
||||
onChange={(event) => setMeasures(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="inspection-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Durchsicht speichern"}
|
||||
</Button>
|
||||
{({ describedBy, invalid }) => (
|
||||
<Select
|
||||
value={values.colonyId}
|
||||
onValueChange={(value) => set("colonyId", value ?? "")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inspection-colony"
|
||||
className="h-11 w-full"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
coloniesLoading
|
||||
? "Völker werden geladen…"
|
||||
: "Volk wählen"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeColonies.map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Datum" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
value={values.date}
|
||||
onChange={(event) => set("date", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Königin & Brut"
|
||||
description="Antippen, was du gesehen hast."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<ToggleChip
|
||||
label="Königin gesehen"
|
||||
checked={values.queenSeen}
|
||||
onChange={(checked) => set("queenSeen", checked)}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Stifte vorhanden"
|
||||
checked={values.eggsPresent}
|
||||
onChange={(checked) => set("eggsPresent", checked)}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Offene Brut"
|
||||
checked={values.openBroodPresent}
|
||||
onChange={(checked) => set("openBroodPresent", checked)}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Verdeckelte Brut"
|
||||
checked={values.cappedBroodPresent}
|
||||
onChange={(checked) => set("cappedBroodPresent", checked)}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Weiselzellen"
|
||||
checked={values.queenCellsPresent}
|
||||
onChange={(checked) => set("queenCellsPresent", checked)}
|
||||
/>
|
||||
</div>
|
||||
{noQueenSigns ? (
|
||||
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0 text-warning"
|
||||
aria-hidden
|
||||
/>
|
||||
Weder Königin noch Stifte gesehen. Nach dem Speichern wird
|
||||
eine Kontrolle als Aufgabe vorgeschlagen.
|
||||
</p>
|
||||
) : null}
|
||||
<Field label="Brutbild">
|
||||
{({ id, labelId, describedBy }) => (
|
||||
<RatingScale
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
describedBy={describedBy}
|
||||
value={values.broodPattern}
|
||||
onChange={(rating) => set("broodPattern", rating)}
|
||||
lowLabel="lückenhaft"
|
||||
highLabel="geschlossen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Brutmenge" hint="z. B. „4 Waben“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={values.broodAmount}
|
||||
onChange={(event) => set("broodAmount", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={3} title="Stärke & Verhalten">
|
||||
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={values.colonyStrength}
|
||||
onChange={(event) =>
|
||||
set("colonyStrength", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Sanftmut">
|
||||
{({ id, labelId }) => (
|
||||
<RatingScale
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
value={values.gentleness}
|
||||
onChange={(rating) => set("gentleness", rating)}
|
||||
lowLabel="stechlustig"
|
||||
highLabel="sehr sanft"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Wabensitz">
|
||||
{({ id, labelId }) => (
|
||||
<RatingScale
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
value={values.combBehavior}
|
||||
onChange={(rating) => set("combBehavior", rating)}
|
||||
lowLabel="läuft weg"
|
||||
highLabel="sitzt ruhig"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Schwarmstimmung">
|
||||
{({ id, labelId }) => (
|
||||
<RatingScale
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
value={values.swarmMood}
|
||||
onChange={(rating) => set("swarmMood", rating)}
|
||||
lowLabel="keine"
|
||||
highLabel="stark"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={4} title="Futter">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Futterstatus"
|
||||
className="grid grid-cols-3 gap-2"
|
||||
>
|
||||
{FEED_STATUS_OPTIONS.map((option) => {
|
||||
const selected = values.feedStatus === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => set("feedStatus", option.value)}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary font-medium text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={5}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={7}
|
||||
>
|
||||
<Field label="Wetter">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.weather}
|
||||
onChange={(event) => set("weather", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Temperatur (°C)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="h-11"
|
||||
value={values.temperature}
|
||||
onChange={(event) => set("temperature", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Honigraum">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.honeySuperStatus}
|
||||
onChange={(event) =>
|
||||
set("honeySuperStatus", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Varroa-Befund">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.varroaIssues}
|
||||
onChange={(event) =>
|
||||
set("varroaIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Gesundheitsauffälligkeiten">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.healthIssues}
|
||||
onChange={(event) =>
|
||||
set("healthIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Maßnahmen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.measures}
|
||||
onChange={(event) => set("measures", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Durchsicht speichern"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user