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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:32:20 +02:00
1 parent 8b6f48b996
commit 32dfd8d8a7
11 files changed
+973 -278

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 { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ClipboardCheck } from "lucide-react"; import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { createInspection } from "@/lib/inspections/api"; import { createInspection } from "@/lib/inspections/api";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { queueInspection } from "@/lib/offline/db"; import { queueInspection } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
Select, Select,
@@ -23,52 +24,48 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
const FEED_STATUS_LABELS = { import { FormSection } from "@/components/form/form-section";
ausreichend: "Ausreichend", import { RatingScale } from "@/components/form/rating-scale";
niedrig: "Niedrig", import { ToggleChip } from "@/components/form/toggle-chip";
kritisch: "Kritisch", import { StickySubmit } from "@/components/form/sticky-submit";
} as const; import { DraftBanner } from "@/components/form/draft-banner";
import { cn } from "@/lib/utils";
function todayIsoDate(): string { import {
return new Date().toISOString().slice(0, 10); FEED_STATUS_OPTIONS,
} InspectionFormValues,
initialInspectionValues,
toInspectionInput,
} from "./inspection-form-values";
export default function NewInspectionPage() { export default function NewInspectionPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: colonies } = useQuery({ const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"], queryKey: ["colonies"],
queryFn: () => fetchColonies(), 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 [values, setValues] = useState<InspectionFormValues>(() =>
const [date, setDate] = useState(todayIsoDate()); initialInspectionValues(searchParams.get("colonyId") ?? ""),
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 [honeySuperStatus, setHoneySuperStatus] = useState(""); const [dirty, setDirty] = useState(false);
const [healthIssues, setHealthIssues] = useState(""); const [colonyError, setColonyError] = useState<string | null>(null);
const [varroaIssues, setVarroaIssues] = useState(""); const [submitError, setSubmitError] = useState<string | null>(null);
const [measures, setMeasures] = useState(""); const draft = useFormDraft("inspection-new", values, dirty);
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null); 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 // 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. // 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]) => mutationFn: (input: Parameters<typeof createInspection>[0]) =>
createInspection(input, { idempotencyKey }), createInspection(input, { idempotencyKey }),
onSuccess: (inspection) => { onSuccess: (inspection) => {
draft.clear();
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"], queryKey: ["colonies", inspection.colonyId, "inspections"],
}); });
@@ -85,11 +83,19 @@ export default function NewInspectionPage() {
}, },
onError: (mutationError, variables) => { onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) { if (mutationError instanceof ApiError) {
setError("Durchsicht konnte nicht gespeichert werden."); setSubmitError(
errorMessage(
mutationError,
"Durchsicht konnte nicht gespeichert werden.",
),
);
return; return;
} }
void queueInspection(variables, idempotencyKey).then(() => { 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}`); router.push(`/colonies/${variables.colonyId}`);
}); });
}, },
@@ -97,52 +103,76 @@ export default function NewInspectionPage() {
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(null); setSubmitError(null);
if (!colonyId) { if (!values.colonyId) {
setError("Bitte ein Volk auswählen."); setColonyError("Bitte ein Volk auswählen.");
document.getElementById("inspection-colony")?.focus();
return; return;
} }
mutation.mutate({ mutation.mutate(toInspectionInput(values));
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,
});
} }
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
const noQueenSigns = !values.queenSeen && !values.eggsPresent;
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={ClipboardCheck} icon={ClipboardCheck}
title="Neue Durchsicht" 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)" gradient="var(--gradient-sage)"
/> />
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg"> <Card className="max-w-lg">
<CardContent> <CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}> <form
<div className="flex flex-col gap-2"> className="flex flex-col gap-6"
<Label htmlFor="inspection-colony">Volk</Label> onSubmit={handleSubmit}
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}> noValidate
<SelectTrigger id="inspection-colony" className="h-11 w-full"> >
<SelectValue placeholder="Volk wählen" /> <FormSection step={1} title="Welches Volk, wann?">
<Field
label="Volk"
required
error={colonyError}
hint={
!coloniesLoading && activeColonies.length === 0
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
: undefined
}
>
{({ 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> </SelectTrigger>
<SelectContent> <SelectContent>
{activeColonies.map((colony) => ( {activeColonies.map((colony) => (
@@ -152,239 +182,281 @@ export default function NewInspectionPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-date">Datum</Label> <Field label="Datum" required>
{({ id }) => (
<Input <Input
id="inspection-date" id={id}
type="date" type="date"
required required
className="h-11" className="h-11"
value={date} value={values.date}
onChange={(event) => setDate(event.target.value)} 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> </div>
<div className="flex flex-col gap-2"> {noQueenSigns ? (
<Label htmlFor="inspection-weather">Wetter</Label> <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 <Input
id="inspection-weather" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={weather} value={values.broodAmount}
onChange={(event) => setWeather(event.target.value)} onChange={(event) => set("broodAmount", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-temperature">Temperatur (°C)</Label> </FormSection>
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input <Input
id="inspection-temperature" 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" type="number"
inputMode="decimal" inputMode="decimal"
className="h-11" className="h-11"
value={temperature} value={values.temperature}
onChange={(event) => setTemperature(event.target.value)} onChange={(event) => set("temperature", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-strength">Volksstärke (Waben)</Label> <Field label="Honigraum">
{({ id }) => (
<Input <Input
id="inspection-strength" id={id}
type="number"
inputMode="numeric"
className="h-11" className="h-11"
value={colonyStrength} value={values.honeySuperStatus}
onChange={(event) => setColonyStrength(event.target.value)} onChange={(event) =>
set("honeySuperStatus", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-brood-amount">Brutmenge</Label> <Field label="Varroa-Befund">
{({ id }) => (
<Input <Input
id="inspection-brood-amount" id={id}
className="h-11" className="h-11"
value={broodAmount} value={values.varroaIssues}
onChange={(event) => setBroodAmount(event.target.value)} onChange={(event) =>
set("varroaIssues", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-brood-pattern">Brutbild (1–5)</Label> <Field label="Gesundheitsauffälligkeiten">
{({ id }) => (
<Input <Input
id="inspection-brood-pattern" id={id}
type="number"
min={1}
max={5}
className="h-11" className="h-11"
value={broodPattern} value={values.healthIssues}
onChange={(event) => setBroodPattern(event.target.value)} onChange={(event) =>
set("healthIssues", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-3"> </Field>
<label <Field label="Maßnahmen">
className="flex h-11 items-center gap-3 text-sm" {({ id }) => (
htmlFor="inspection-open-brood" <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"
> >
<input {submitError}
id="inspection-open-brood" </p>
type="checkbox" ) : null
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)
} }
> >
<SelectTrigger id="inspection-feed-status" className="h-11 w-full"> <Button
<SelectValue>{FEED_STATUS_LABELS[feedStatus]}</SelectValue> type="submit"
</SelectTrigger> className="h-12 text-base"
<SelectContent> disabled={mutation.isPending}
<SelectItem value="ausreichend">Ausreichend</SelectItem> >
<SelectItem value="niedrig">Niedrig</SelectItem> {mutation.isPending
<SelectItem value="kritisch">Kritisch</SelectItem> ? "Wird gespeichert…"
</SelectContent> : "Durchsicht speichern"}
</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> </Button>
</StickySubmit>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -0,0 +1,44 @@
"use client";
import { History } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DraftBannerProps {
savedAt: Date;
onRestore: () => void;
onDiscard: () => void;
}
/**
* Offers an unfinished entry back - never applies it silently, so the form
* is always what the person expects to see.
*/
export function DraftBanner({
savedAt,
onRestore,
onDiscard,
}: DraftBannerProps) {
const time = savedAt.toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "short",
});
return (
<section
aria-live="polite"
className="flex flex-col gap-3 rounded-xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center"
>
<p className="flex flex-1 items-center gap-2 text-sm">
<History className="size-4 shrink-0" aria-hidden />
Unfertiger Eintrag vom {time} gefunden.
</p>
<div className="flex gap-2">
<Button type="button" size="touch" onClick={onRestore}>
Weitermachen
</Button>
<Button type="button" size="touch" variant="ghost" onClick={onDiscard}>
Verwerfen
</Button>
</div>
</section>
);
}
@@ -0,0 +1,77 @@
import { ReactNode, useId } from "react";
import { cn } from "@/lib/utils";
interface FieldProps {
label: string;
/** Marks the field "Pflicht"; everything else is visibly "optional". */
required?: boolean;
hint?: string;
error?: string | null;
className?: string;
/** Receives the ids the control must carry for label, hint and error. */
children: (ids: {
id: string;
labelId: string;
describedBy: string | undefined;
invalid: boolean;
}) => ReactNode;
}
/**
* One form field with everything in one place: label, whether it is needed,
* a short hint and - right next to it, not in a toast that vanishes - what
* is wrong. Fewer things to keep in mind while filling a form.
*/
export function Field({
label,
required,
hint,
error,
className,
children,
}: FieldProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<label
id={`${id}-label`}
htmlFor={id}
className="flex items-baseline gap-2 text-sm font-medium"
>
{label}
<span
className={cn(
"text-xs font-normal",
required ? "text-foreground/80" : "text-muted-foreground",
)}
>
{required ? "Pflicht" : "optional"}
</span>
</label>
{children({
id,
labelId: `${id}-label`,
describedBy,
invalid: Boolean(error),
})}
{hint ? (
<p id={hintId} className="text-xs text-muted-foreground">
{hint}
</p>
) : null}
{error ? (
<p
id={errorId}
role="alert"
className="text-sm font-medium text-destructive"
>
{error}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { ReactNode, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface FormSectionProps {
/** Shown as "1", "2", ... so the way through the form is visible. */
step?: number;
title: string;
description?: string;
/**
* Optional sections start folded: the form looks as short as what is
* actually needed, the rest is one tap away and says how many fields
* it holds.
*/
collapsible?: boolean;
defaultOpen?: boolean;
fieldCount?: number;
children: ReactNode;
}
export function FormSection({
step,
title,
description,
collapsible = false,
defaultOpen = true,
fieldCount,
children,
}: FormSectionProps) {
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
const heading = (
<span className="flex items-center gap-2">
{step !== undefined ? (
<span
aria-hidden
className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
>
{step}
</span>
) : null}
<span className="font-heading text-base font-semibold">{title}</span>
</span>
);
return (
<fieldset className="flex flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
{collapsible ? (
<legend className="contents">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg text-left"
>
{heading}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{open
? "Einklappen"
: fieldCount
? `${fieldCount} Angaben, optional`
: "Aufklappen"}
<ChevronDown
aria-hidden
className={cn(
"size-4 motion-safe:transition-transform",
open && "rotate-180",
)}
/>
</span>
</button>
</legend>
) : (
<legend className="contents">{heading}</legend>
)}
{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}
</fieldset>
);
}
@@ -0,0 +1,68 @@
"use client";
import { cn } from "@/lib/utils";
interface RatingScaleProps {
id: string;
value: number | null;
onChange: (value: number | null) => void;
/** Words for the ends, e.g. "stechlustig" / "sehr sanft". */
lowLabel: string;
highLabel: string;
describedBy?: string;
/** Id of the visible label, so the group is announced by name. */
labelledBy?: string;
}
/**
* 1-5 as five large buttons instead of a number field: one tap with gloves,
* no keyboard, and the meaning of both ends stays on screen. Tapping the
* chosen value again clears it.
*/
export function RatingScale({
id,
value,
onChange,
lowLabel,
highLabel,
describedBy,
labelledBy,
}: RatingScaleProps) {
return (
<div className="flex flex-col gap-1">
<div
id={id}
role="radiogroup"
aria-labelledby={labelledBy}
aria-describedby={describedBy}
className="grid grid-cols-5 gap-2"
>
{[1, 2, 3, 4, 5].map((option) => {
const selected = value === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
aria-label={`${option} von 5`}
onClick={() => onChange(selected ? null : option)}
className={cn(
"h-11 rounded-lg border text-base font-semibold motion-safe:transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option}
</button>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 = {lowLabel}</span>
<span>5 = {highLabel}</span>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { ReactNode } from "react";
/**
* Keeps the one primary action within thumb reach on a phone, however long
* the form is; on wider screens it simply ends the form.
*/
export function StickySubmit({
children,
note,
}: {
children: ReactNode;
note?: ReactNode;
}) {
return (
<div className="sticky bottom-16 z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
{note}
{children}
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface ToggleChipProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
/** A yes/no observation as a large tile; state shown by tick AND colour. */
export function ToggleChip({ label, checked, onChange }: ToggleChipProps) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors",
checked
? "border-primary bg-primary/10 font-medium"
: "border-border bg-background hover:bg-muted",
)}
>
<span
aria-hidden
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded border",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-border",
)}
>
{checked ? <Check className="size-3.5" /> : null}
</span>
{label}
</button>
);
}
@@ -0,0 +1,12 @@
import { ApiError } from "@/lib/auth/api-client";
/**
* The server's own explanation when there is one (it names the problem,
* e.g. "Code wird bereits verwendet"), otherwise a plain fallback.
*/
export function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.message) {
return error.message;
}
return fallback;
}
@@ -0,0 +1,94 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const PREFIX = "apicore:draft:";
const SAVE_DELAY_MS = 600;
// Drafts older than this are stale (the situation at the hive has changed).
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
interface StoredDraft<T> {
savedAt: number;
values: T;
}
export interface FormDraft<T> {
/** A draft from an earlier visit, offered until restored or discarded. */
pending: { savedAt: Date; values: T } | null;
restore: () => T | null;
discard: () => void;
/** Call after a successful save. */
clear: () => void;
}
function read<T>(key: string): StoredDraft<T> | null {
try {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredDraft<T>;
if (typeof parsed.savedAt !== "number" || Date.now() - parsed.savedAt > MAX_AGE_MS) {
window.localStorage.removeItem(PREFIX + key);
return null;
}
return parsed;
} catch {
return null;
}
}
function remove(key: string): void {
try {
window.localStorage.removeItem(PREFIX + key);
} catch {
// Storage unavailable (private mode) - nothing to remove.
}
}
/**
* Keeps an unfinished form on this device: an interruption (a call, a
* sting, a dead battery) does not cost the typed-in entry. Saves only once
* something was changed (`dirty`), never sends anything anywhere.
*/
export function useFormDraft<T>(key: string, values: T, dirty: boolean): FormDraft<T> {
const [pending, setPending] = useState<FormDraft<T>["pending"]>(null);
const offered = useRef(false);
useEffect(() => {
if (offered.current) return;
offered.current = true;
const stored = read<T>(key);
if (stored) {
// Read after mount: localStorage does not exist during server render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPending({ savedAt: new Date(stored.savedAt), values: stored.values });
}
}, [key]);
useEffect(() => {
if (!dirty || pending) return;
const timer = window.setTimeout(() => {
try {
const draft: StoredDraft<T> = { savedAt: Date.now(), values };
window.localStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
// Storage full or blocked - the form still works, just without a draft.
}
}, SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [key, values, dirty, pending]);
const restore = useCallback(() => {
const values = pending?.values ?? null;
setPending(null);
return values;
}, [pending]);
const discard = useCallback(() => {
remove(key);
setPending(null);
}, [key]);
const clear = useCallback(() => remove(key), [key]);
return { pending, restore, discard, clear };
}