diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts new file mode 100644 index 0000000..c9744c9 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts @@ -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[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), + }; +} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx new file mode 100644 index 0000000..602eae4 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 5240d34..527c684 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -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(() => + 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(null); + const [dirty, setDirty] = useState(false); + const [colonyError, setColonyError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const draft = useFormDraft("inspection-new", values, dirty); + + function set( + 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[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) { 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 (
+ {draft.pending ? ( + + ) : null} -
-
- - -
-
- - + + setDate(event.target.value)} - /> -
-
- - setWeather(event.target.value)} - /> -
-
- - setTemperature(event.target.value)} - /> -
-
- - setColonyStrength(event.target.value)} - /> -
-
- - setBroodAmount(event.target.value)} - /> -
-
- - setBroodPattern(event.target.value)} - /> -
-
- - - - - -
-
- - setSwarmMood(event.target.value)} - /> -
-
- - setGentleness(event.target.value)} - /> -
-
- - setCombBehavior(event.target.value)} - /> -
-
- - -
-
- - setHoneySuperStatus(event.target.value)} - /> -
-
- - setVarroaIssues(event.target.value)} - /> -
-
- - setHealthIssues(event.target.value)} - /> -
-
- -