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>
|
||||
|
||||
@@ -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