From 62e78498bf93684b1099d2217f1e7a33e769561a Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 12:14:07 +0200 Subject: [PATCH] feat(ui): capture forms for treatments, feedings, colonies, apiaries, products, equipment Same structure as the inspection form: required fields marked, errors at the field, numbered sections with rarely used fields folded, drafts kept on the device, save button in thumb reach, plain hints for jargon (Wirkstoff, Wartezeit, Code, SKU, units). The Bestandsbuch fields of a treatment stay a visible section of their own. Apiaries can take the coordinates from the phone's location. Offline queue and idempotency are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../(app)/apiaries/new/apiary-form-values.ts | 52 ++ .../src/app/(app)/apiaries/new/page.spec.tsx | 133 +++++ .../src/app/(app)/apiaries/new/page.tsx | 319 +++++++--- .../(app)/colonies/new/colony-form-values.ts | 43 ++ .../src/app/(app)/colonies/new/page.spec.tsx | 77 +++ .../src/app/(app)/colonies/new/page.tsx | 292 ++++++--- .../equipment/new/equipment-form-values.ts | 56 ++ .../src/app/(app)/equipment/new/page.spec.tsx | 78 +++ .../src/app/(app)/equipment/new/page.tsx | 259 +++++--- .../(app)/feedings/new/feeding-form-values.ts | 56 ++ .../src/app/(app)/feedings/new/page.spec.tsx | 89 +++ .../src/app/(app)/feedings/new/page.tsx | 442 +++++++++----- .../src/app/(app)/products/new/page.spec.tsx | 74 +++ .../src/app/(app)/products/new/page.tsx | 263 +++++--- .../(app)/products/new/product-form-values.ts | 47 ++ .../app/(app)/treatments/new/page.spec.tsx | 94 +++ .../src/app/(app)/treatments/new/page.tsx | 564 +++++++++++------- .../treatments/new/treatment-form-values.ts | 73 +++ 18 files changed, 2326 insertions(+), 685 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/new/colony-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/equipment/new/equipment-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/equipment/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/feedings/new/feeding-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/products/new/product-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/treatments/new/treatment-form-values.ts diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts new file mode 100644 index 0000000..f5c59d3 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts @@ -0,0 +1,52 @@ +import type { ApiaryInput } from "@/lib/apiaries/types"; + +/** Everything the form holds, as plain values that survive a draft save. */ +export interface ApiaryFormValues { + name: string; + location: string; + latitude: string; + longitude: string; + address: string; + notes: string; +} + +export type ApiaryFormErrors = Partial>; + +export function initialApiaryValues(): ApiaryFormValues { + return { + name: "", + location: "", + latitude: "", + longitude: "", + address: "", + notes: "", + }; +} + +/** Required fields in form order, so the first error is the first field. */ +export function validateApiary(values: ApiaryFormValues): ApiaryFormErrors { + const errors: ApiaryFormErrors = {}; + if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben."; + return errors; +} + +/** Six decimals are about 10 cm - more precise than any phone GPS. */ +export function formatCoordinate(value: number): string { + return value.toFixed(6); +} + +const textOrUndefined = (value: string) => + value.trim() ? value.trim() : undefined; +const numberOrUndefined = (value: string) => + value.trim() ? Number(value) : undefined; + +export function toApiaryInput(values: ApiaryFormValues): ApiaryInput { + return { + name: values.name.trim(), + location: textOrUndefined(values.location), + latitude: numberOrUndefined(values.latitude), + longitude: numberOrUndefined(values.longitude), + address: textOrUndefined(values.address), + notes: textOrUndefined(values.notes), + }; +} diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx new file mode 100644 index 0000000..690c0f4 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx @@ -0,0 +1,133 @@ +import { afterEach, 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 NewApiaryPage from "./page"; +import { createApiary } from "@/lib/apiaries/api"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@/lib/apiaries/api", () => ({ createApiary: vi.fn() })); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +function mockGeolocation( + getCurrentPosition: Geolocation["getCurrentPosition"], +) { + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { getCurrentPosition }, + }); +} + +describe("NewApiaryPage", () => { + beforeEach(() => { + window.localStorage.clear(); + push.mockReset(); + vi.mocked(createApiary).mockReset(); + }); + + afterEach(() => { + Reflect.deleteProperty(navigator, "geolocation"); + }); + + it("marks only the name as required", () => { + renderPage(); + expect(screen.getAllByText("Pflicht")).toHaveLength(1); + expect( + screen.getByRole("button", { name: /Notizen/ }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("creates the apiary with the entered values", async () => { + vi.mocked(createApiary).mockResolvedValue({ id: "apiary-3" } as never); + renderPage(); + + await userEvent.type(screen.getByLabelText(/Name/), "Hausgarten"); + await userEvent.type(screen.getByLabelText(/Adresse/), "Dorfstraße 1"); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + + expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({ + name: "Hausgarten", + location: undefined, + latitude: undefined, + longitude: undefined, + address: "Dorfstraße 1", + notes: undefined, + }); + expect(push).toHaveBeenCalledWith("/apiaries/apiary-3"); + }); + + it("shows an inline error and does not save without a name", async () => { + renderPage(); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + + expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument(); + expect(screen.getByLabelText(/Name/)).toHaveFocus(); + expect(createApiary).not.toHaveBeenCalled(); + }); + + it("fills latitude and longitude from the device location", async () => { + mockGeolocation((success) => + success({ + coords: { latitude: 52.5200081, longitude: 13.4049541 }, + } as GeolocationPosition), + ); + vi.mocked(createApiary).mockResolvedValue({ id: "apiary-4" } as never); + renderPage(); + + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(52.520008); + expect(screen.getByLabelText(/Längengrad/)).toHaveValue(13.404954); + + await userEvent.type(screen.getByLabelText(/Name/), "Waldrand"); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + expect(vi.mocked(createApiary).mock.calls[0][0]).toMatchObject({ + latitude: 52.520008, + longitude: 13.404954, + }); + }); + + it("explains inline when the location permission is denied", async () => { + mockGeolocation((_success, failure) => + failure?.({ code: 1 } as GeolocationPositionError), + ); + renderPage(); + + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByRole("status")).toHaveTextContent( + /Standort-Freigabe abgelehnt/, + ); + expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(null); + }); + + it("explains inline when the device has no location service", async () => { + renderPage(); + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByRole("status")).toHaveTextContent(/nicht verfügbar/); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx index a8f1ece..ec15683 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx @@ -2,49 +2,120 @@ import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; -import { MapPin } from "lucide-react"; +import { LocateFixed, MapPin } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { createApiary } from "@/lib/apiaries/api"; +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 { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; +import { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; +import { StickySubmit } from "@/components/form/sticky-submit"; +import { DraftBanner } from "@/components/form/draft-banner"; +import { + ApiaryFormErrors, + ApiaryFormValues, + formatCoordinate, + initialApiaryValues, + toApiaryInput, + validateApiary, +} from "./apiary-form-values"; + +const PERMISSION_DENIED = 1; +const LOCATE_TIMEOUT_MS = 15_000; export default function NewApiaryPage() { const router = useRouter(); const queryClient = useQueryClient(); - const [name, setName] = useState(""); - const [location, setLocation] = useState(""); - const [latitude, setLatitude] = useState(""); - const [longitude, setLongitude] = useState(""); - const [address, setAddress] = useState(""); - const [notes, setNotes] = useState(""); - const [error, setError] = useState(null); + const [values, setValues] = useState(initialApiaryValues); + const [dirty, setDirty] = useState(false); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const [locating, setLocating] = useState(false); + const [locateMessage, setLocateMessage] = useState(null); + const draft = useFormDraft("apiary-new", values, dirty); + + function set( + key: K, + value: ApiaryFormValues[K], + ) { + setValues((current) => ({ ...current, [key]: value })); + setDirty(true); + setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current)); + } + + function takeMyLocation() { + setLocateMessage(null); + if (typeof navigator === "undefined" || !navigator.geolocation) { + setLocateMessage( + "Standort ist auf diesem Gerät nicht verfügbar – bitte die Koordinaten selbst eintragen.", + ); + return; + } + setLocating(true); + navigator.geolocation.getCurrentPosition( + (position) => { + setLocating(false); + setValues((current) => ({ + ...current, + latitude: formatCoordinate(position.coords.latitude), + longitude: formatCoordinate(position.coords.longitude), + })); + setDirty(true); + }, + (positionError) => { + setLocating(false); + setLocateMessage( + positionError.code === PERMISSION_DENIED + ? "Standort-Freigabe abgelehnt – du kannst sie in den Browser-Einstellungen erlauben oder die Koordinaten selbst eintragen." + : "Standort konnte nicht ermittelt werden – bitte später erneut versuchen oder selbst eintragen.", + ); + }, + { enableHighAccuracy: true, timeout: LOCATE_TIMEOUT_MS }, + ); + } const mutation = useMutation({ mutationFn: createApiary, onSuccess: (apiary) => { + draft.clear(); void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); + toast.success("Bienenstand angelegt"); router.push(`/apiaries/${apiary.id}`); }, - onError: () => { - setError("Bienenstand konnte nicht angelegt werden."); + onError: (mutationError) => { + setSubmitError( + errorMessage(mutationError, "Bienenstand konnte nicht angelegt werden."), + ); }, }); function handleSubmit(event: FormEvent) { event.preventDefault(); - setError(null); - mutation.mutate({ - name, - location: location || undefined, - latitude: latitude ? Number(latitude) : undefined, - longitude: longitude ? Number(longitude) : undefined, - address: address || undefined, - notes: notes || undefined, - }); + setSubmitError(null); + const found = validateApiary(values); + setErrors(found); + const firstInvalid = Object.keys(found)[0]; + if (firstInvalid) { + event.currentTarget + .querySelector(`[data-field="${firstInvalid}"]`) + ?.focus(); + return; + } + mutation.mutate(toApiaryInput(values)); + } + + function restoreDraft() { + const restored = draft.restore(); + if (restored) { + setValues(restored); + setDirty(true); + } } return ( @@ -52,78 +123,152 @@ export default function NewApiaryPage() { + {draft.pending ? ( + + ) : null} -
-
- - setName(event.target.value)} - /> -
-
- - setLocation(event.target.value)} - /> -
-
-
- - setLatitude(event.target.value)} - /> + + + + {({ id, describedBy, invalid }) => ( + set("name", event.target.value)} + /> + )} + + + {({ id, describedBy }) => ( + set("location", event.target.value)} + /> + )} + + + + +
+ + {locateMessage ? ( +

+ {locateMessage} +

+ ) : null}
-
- - setLongitude(event.target.value)} - /> +
+ + {({ id, describedBy }) => ( + set("latitude", event.target.value)} + /> + )} + + + {({ id, describedBy }) => ( + set("longitude", event.target.value)} + /> + )} +
-
-
- - setAddress(event.target.value)} - /> -
-
- -