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
+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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user