diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/bottling-form-values.ts b/ApiCore-Frontend/src/app/(app)/bottling/new/bottling-form-values.ts
new file mode 100644
index 0000000..78897db
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/bottling/new/bottling-form-values.ts
@@ -0,0 +1,42 @@
+import type { createBottlingBatch } from "@/lib/bottling/api";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface BottlingFormValues {
+ honeyBatchId: string;
+ productId: string;
+ date: string;
+ jarSize: string;
+ jarCount: string;
+ bestBeforeDate: string;
+ lotNumber: string;
+}
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialBottlingValues(honeyBatchId: string): BottlingFormValues {
+ return {
+ honeyBatchId,
+ productId: "",
+ date: todayIsoDate(),
+ jarSize: "500",
+ jarCount: "",
+ bestBeforeDate: "",
+ lotNumber: "",
+ };
+}
+
+export function toBottlingInput(
+ values: BottlingFormValues,
+): Parameters[0] {
+ return {
+ honeyBatchId: values.honeyBatchId,
+ productId: values.productId || undefined,
+ date: new Date(values.date).toISOString(),
+ jarSize: Number(values.jarSize),
+ jarCount: Number(values.jarCount),
+ bestBeforeDate: new Date(values.bestBeforeDate).toISOString(),
+ lotNumber: values.lotNumber.trim() ? values.lotNumber.trim() : undefined,
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx
new file mode 100644
index 0000000..f5c9cfd
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx
@@ -0,0 +1,89 @@
+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 NewBottlingBatchPage from "./page";
+import { createBottlingBatch } from "@/lib/bottling/api";
+import { fetchHoneyBatches } from "@/lib/honey-batches/api";
+import { fetchProducts } from "@/lib/products/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"),
+}));
+vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() }));
+vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
+vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewBottlingBatchPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchHoneyBatches).mockResolvedValue([
+ { id: "hb-1", batchNumber: "2026-01", amount: 40 },
+ ] as never);
+ vi.mocked(fetchProducts).mockResolvedValue([] as never);
+ vi.mocked(createBottlingBatch).mockReset();
+ });
+
+ it("marks the five required fields and explains the units", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(5);
+ expect(screen.getByText(/in Gramm/)).toBeInTheDocument();
+ expect(screen.getByText(/in Stück/)).toBeInTheDocument();
+ expect(screen.queryByLabelText(/Los-\/Chargennummer/)).not.toBeInTheDocument();
+ });
+
+ it("sends the bottling with jar size, count and best-before date", async () => {
+ vi.mocked(createBottlingBatch).mockResolvedValue({ id: "b-1" } as never);
+ renderPage();
+
+ await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
+ await userEvent.type(
+ screen.getByLabelText(/Mindesthaltbarkeitsdatum/),
+ "2028-07-01",
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Abfüllung anlegen" }),
+ );
+
+ const input = vi.mocked(createBottlingBatch).mock.calls[0][0];
+ expect(input).toMatchObject({
+ honeyBatchId: "hb-1",
+ jarSize: 500,
+ jarCount: 24,
+ bestBeforeDate: new Date("2028-07-01").toISOString(),
+ });
+ expect(input.productId).toBeUndefined();
+ expect(push).toHaveBeenCalledWith("/bottling/b-1");
+ });
+
+ it("shows an inline error and focuses the jar count when it is missing", async () => {
+ renderPage();
+ await userEvent.type(
+ screen.getByLabelText(/Mindesthaltbarkeitsdatum/),
+ "2028-07-01",
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Abfüllung anlegen" }),
+ );
+
+ expect(
+ screen.getByText("Bitte die Anzahl Gläser angeben."),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveFocus();
+ expect(createBottlingBatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
index 8ce0348..498108a 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
@@ -3,13 +3,15 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
-import { Package } from "lucide-react";
import { createBottlingBatch } from "@/lib/bottling/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 {
Select,
SelectContent,
@@ -19,22 +21,68 @@ import {
} from "@/components/ui/select";
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 {
+ BottlingFormValues,
+ initialBottlingValues,
+ toBottlingInput,
+} from "./bottling-form-values";
+
+type RequiredKey =
+ | "honeyBatchId"
+ | "date"
+ | "jarSize"
+ | "jarCount"
+ | "bestBeforeDate";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = [
+ "honeyBatchId",
+ "date",
+ "jarSize",
+ "jarCount",
+ "bestBeforeDate",
+];
+
+function isPositiveNumber(value: string): boolean {
+ return value.trim() !== "" && Number(value) > 0;
+}
+
+function validate(values: BottlingFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.honeyBatchId) {
+ errors.honeyBatchId = "Bitte eine Honigcharge auswählen.";
+ }
+ if (!values.date) errors.date = "Bitte ein Datum angeben.";
+ if (!isPositiveNumber(values.jarSize)) {
+ errors.jarSize = "Bitte die Glasgröße in Gramm angeben.";
+ }
+ if (!isPositiveNumber(values.jarCount)) {
+ errors.jarCount = "Bitte die Anzahl Gläser angeben.";
+ }
+ if (!values.bestBeforeDate) {
+ errors.bestBeforeDate = "Bitte das Mindesthaltbarkeitsdatum angeben.";
+ }
+ return errors;
+}
export default function NewBottlingBatchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
- const [honeyBatchId, setHoneyBatchId] = useState(searchParams.get("honeyBatchId") ?? "");
- const [productId, setProductId] = useState("");
- const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
- const [jarSize, setJarSize] = useState("500");
- const [jarCount, setJarCount] = useState("");
- const [bestBeforeDate, setBestBeforeDate] = useState("");
- const [lotNumber, setLotNumber] = useState("");
- const [error, setError] = useState(null);
+ const [values, setValues] = useState(() =>
+ initialBottlingValues(searchParams.get("honeyBatchId") ?? ""),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("bottling-new", values, dirty);
- const { data: honeyBatches } = useQuery({
+ const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({
queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches(),
});
@@ -43,30 +91,54 @@ export default function NewBottlingBatchPage() {
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
+ function set(
+ key: K,
+ value: BottlingFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
+
const mutation = useMutation({
mutationFn: createBottlingBatch,
onSuccess: (bottlingBatch) => {
+ draft.clear();
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
+ toast.success("Abfüllung angelegt");
router.push(`/bottling/${bottlingBatch.id}`);
},
onError: (mutationError: Error) => {
- setError(mutationError.message || "Abfüllung konnte nicht angelegt werden.");
+ setSubmitError(
+ errorMessage(mutationError, "Abfüllung konnte nicht angelegt werden."),
+ );
},
});
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- mutation.mutate({
- honeyBatchId,
- productId: productId || undefined,
- date: new Date(date).toISOString(),
- jarSize: Number(jarSize),
- jarCount: Number(jarCount),
- bestBeforeDate: new Date(bestBeforeDate).toISOString(),
- lotNumber: lotNumber || undefined,
- });
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
+ return;
+ }
+ mutation.mutate(toBottlingInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
}
return (
@@ -76,103 +148,213 @@ export default function NewBottlingBatchPage() {
title="Neue Abfüllung"
description="Fülle eine Honigcharge in Gläser ab."
/>
+ {draft.pending ? (
+
+ ) : null}
-
+ ) : null
+ }
+ >
+
+
diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts b/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts
new file mode 100644
index 0000000..2fdcf5c
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts
@@ -0,0 +1,46 @@
+import type { createExtraction } from "@/lib/extractions/api";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface ExtractionFormValues {
+ date: string;
+ harvestIds: string[];
+ amount: string;
+ room: string;
+ waterContent: string;
+ notes: string;
+}
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialExtractionValues(
+ preselectedHarvestId: string | null,
+): ExtractionFormValues {
+ return {
+ date: todayIsoDate(),
+ harvestIds: preselectedHarvestId ? [preselectedHarvestId] : [],
+ amount: "",
+ room: "",
+ waterContent: "",
+ notes: "",
+ };
+}
+
+const textOrUndefined = (value: string) =>
+ value.trim() ? value.trim() : undefined;
+const numberOrUndefined = (value: string) =>
+ value.trim() ? Number(value) : undefined;
+
+export function toExtractionInput(
+ values: ExtractionFormValues,
+): Parameters[0] {
+ return {
+ date: new Date(values.date).toISOString(),
+ harvestIds: values.harvestIds,
+ amount: numberOrUndefined(values.amount),
+ room: textOrUndefined(values.room),
+ waterContent: numberOrUndefined(values.waterContent),
+ notes: textOrUndefined(values.notes),
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx
new file mode 100644
index 0000000..95d8cb4
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx
@@ -0,0 +1,83 @@
+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 NewExtractionPage from "./page";
+import { createExtraction } from "@/lib/extractions/api";
+import { fetchHarvests } from "@/lib/harvests/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+vi.mock("@/lib/harvests/api", () => ({ fetchHarvests: vi.fn() }));
+vi.mock("@/lib/extractions/api", () => ({ createExtraction: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewExtractionPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchHarvests).mockResolvedValue([
+ {
+ id: "harvest-1",
+ date: "2026-07-01T00:00:00.000Z",
+ colonies: [{ colony: { code: "V-01" } }],
+ },
+ ] as never);
+ vi.mocked(createExtraction).mockReset();
+ });
+
+ it("marks date and harvests as required and keeps the room folded", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(2);
+ expect(screen.queryByLabelText(/Schleuderraum/)).not.toBeInTheDocument();
+ expect(screen.getByText(/Refraktometer/)).toBeInTheDocument();
+ });
+
+ it("sends the extraction with the tapped harvest", async () => {
+ vi.mocked(createExtraction).mockResolvedValue({ id: "e-1" } as never);
+ renderPage();
+
+ await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
+ await userEvent.type(screen.getByLabelText(/Menge/), "18.5");
+ await userEvent.type(screen.getByLabelText(/Wassergehalt/), "17");
+ await userEvent.click(
+ screen.getByRole("button", { name: "Schleuderung speichern" }),
+ );
+
+ const input = vi.mocked(createExtraction).mock.calls[0][0];
+ expect(input).toMatchObject({
+ harvestIds: ["harvest-1"],
+ amount: 18.5,
+ waterContent: 17,
+ });
+ expect(input.room).toBeUndefined();
+ expect(push).toHaveBeenCalledWith("/extractions/e-1");
+ });
+
+ it("shows an inline error and focuses the harvests when none is chosen", async () => {
+ renderPage();
+ await screen.findByRole("checkbox", { name: /V-01/ });
+ await userEvent.click(
+ screen.getByRole("button", { name: "Schleuderung speichern" }),
+ );
+
+ expect(
+ screen.getByText("Bitte mindestens eine Ernte auswählen."),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("group", { name: /^Ernten/ })).toHaveFocus();
+ expect(createExtraction).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx
index ef9f2b7..d5bba1f 100644
--- a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx
@@ -7,15 +7,36 @@ import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/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 { ToggleChip } from "@/components/form/toggle-chip";
+import { StickySubmit } from "@/components/form/sticky-submit";
+import { DraftBanner } from "@/components/form/draft-banner";
+import {
+ ExtractionFormValues,
+ initialExtractionValues,
+ toExtractionInput,
+} from "./extraction-form-values";
-function todayIsoDate(): string {
- return new Date().toISOString().slice(0, 10);
+type RequiredKey = "date" | "harvestIds";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["date", "harvestIds"];
+
+function validate(values: ExtractionFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.date) errors.date = "Bitte ein Datum angeben.";
+ if (values.harvestIds.length === 0) {
+ errors.harvestIds = "Bitte mindestens eine Ernte auswählen.";
+ }
+ return errors;
}
export default function NewExtractionPage() {
@@ -23,52 +44,78 @@ export default function NewExtractionPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
- const { data: harvests } = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
+ const { data: harvests } = useQuery({
+ queryKey: ["harvests"],
+ queryFn: fetchHarvests,
+ });
- const preselectedHarvestId = searchParams.get("harvestId");
- const [date, setDate] = useState(todayIsoDate());
- const [harvestIds, setHarvestIds] = useState(
- preselectedHarvestId ? [preselectedHarvestId] : [],
+ const [values, setValues] = useState(() =>
+ initialExtractionValues(searchParams.get("harvestId")),
);
- const [amount, setAmount] = useState("");
- const [room, setRoom] = useState("");
- const [waterContent, setWaterContent] = useState("");
- const [notes, setNotes] = useState("");
- const [error, setError] = useState(null);
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("extraction-new", values, dirty);
+
+ function set(
+ key: K,
+ value: ExtractionFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
+
+ function toggleHarvest(harvestId: string, checked: boolean) {
+ set(
+ "harvestIds",
+ checked
+ ? [...values.harvestIds, harvestId]
+ : values.harvestIds.filter((id) => id !== harvestId),
+ );
+ }
const mutation = useMutation({
mutationFn: createExtraction,
onSuccess: (extraction) => {
+ draft.clear();
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
toast.success("Schleuderung gespeichert");
router.push(`/extractions/${extraction.id}`);
},
- onError: () => {
- setError("Schleuderung konnte nicht gespeichert werden.");
+ onError: (mutationError) => {
+ setSubmitError(
+ errorMessage(
+ mutationError,
+ "Schleuderung konnte nicht gespeichert werden.",
+ ),
+ );
},
});
- function toggleHarvest(harvestId: string, checked: boolean) {
- setHarvestIds((current) =>
- checked ? [...current, harvestId] : current.filter((id) => id !== harvestId),
- );
- }
-
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- if (harvestIds.length === 0) {
- setError("Bitte mindestens eine Ernte auswählen.");
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
return;
}
- mutation.mutate({
- date: new Date(date).toISOString(),
- harvestIds,
- amount: amount ? Number(amount) : undefined,
- room: room || undefined,
- waterContent: waterContent ? Number(waterContent) : undefined,
- notes: notes || undefined,
- });
+ mutation.mutate(toExtractionInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
}
return (
@@ -76,97 +123,164 @@ export default function NewExtractionPage() {
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts b/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts
new file mode 100644
index 0000000..a9320cf
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts
@@ -0,0 +1,50 @@
+import type { createHarvest } from "@/lib/harvests/api";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface HarvestFormValues {
+ apiaryId: string;
+ date: string;
+ colonyIds: string[];
+ honeyType: string;
+ honeySuperCount: string;
+ grossWeight: string;
+ netWeight: string;
+ notes: string;
+}
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialHarvestValues(apiaryId: string): HarvestFormValues {
+ return {
+ apiaryId,
+ date: todayIsoDate(),
+ colonyIds: [],
+ honeyType: "",
+ honeySuperCount: "",
+ grossWeight: "",
+ netWeight: "",
+ notes: "",
+ };
+}
+
+const textOrUndefined = (value: string) =>
+ value.trim() ? value.trim() : undefined;
+const numberOrUndefined = (value: string) =>
+ value.trim() ? Number(value) : undefined;
+
+export function toHarvestInput(
+ values: HarvestFormValues,
+): Parameters[0] {
+ return {
+ apiaryId: values.apiaryId,
+ date: new Date(values.date).toISOString(),
+ colonyIds: values.colonyIds,
+ honeyType: textOrUndefined(values.honeyType),
+ honeySuperCount: numberOrUndefined(values.honeySuperCount),
+ grossWeight: numberOrUndefined(values.grossWeight),
+ netWeight: numberOrUndefined(values.netWeight),
+ notes: textOrUndefined(values.notes),
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx
new file mode 100644
index 0000000..ce7708b
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx
@@ -0,0 +1,93 @@
+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 NewHarvestPage from "./page";
+import { createHarvest } from "@/lib/harvests/api";
+import { fetchColonies } from "@/lib/colonies/api";
+import { fetchApiaries } from "@/lib/apiaries/api";
+import { fetchWithdrawalStatus } from "@/lib/treatments/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams("apiaryId=apiary-1"),
+}));
+vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
+vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
+vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
+vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() }));
+vi.mock("@/lib/offline/db", () => ({ queueHarvest: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewHarvestPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchApiaries).mockResolvedValue([
+ { id: "apiary-1", name: "Garten" },
+ ] as never);
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
+ { id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
+ ] as never);
+ vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
+ vi.mocked(createHarvest).mockReset();
+ });
+
+ it("marks apiary, date and colonies as required and explains gross vs net", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(3);
+ expect(screen.getByText(/Brutto = mit Zargen/)).toBeInTheDocument();
+ expect(screen.getByText(/Netto = nur der Honig/)).toBeInTheDocument();
+ });
+
+ it("sends the harvest with the tapped colonies and weights", async () => {
+ vi.mocked(createHarvest).mockResolvedValue({ id: "h-1" } as never);
+ renderPage();
+
+ await userEvent.click(await screen.findByRole("checkbox", { name: "V-02" }));
+ await userEvent.type(screen.getByLabelText(/Bruttogewicht/), "30.5");
+ await userEvent.type(screen.getByLabelText(/Nettogewicht/), "22");
+ await userEvent.click(
+ screen.getByRole("button", { name: "Ernte speichern" }),
+ );
+
+ const [input, options] = vi.mocked(createHarvest).mock.calls[0];
+ expect(input).toMatchObject({
+ apiaryId: "apiary-1",
+ colonyIds: ["colony-2"],
+ grossWeight: 30.5,
+ netWeight: 22,
+ });
+ expect(input.honeyType).toBeUndefined();
+ expect(options?.idempotencyKey).toBeTruthy();
+ expect(push).toHaveBeenCalledWith("/harvests/h-1");
+ });
+
+ it("shows an inline error and focuses the colonies when none is chosen", async () => {
+ renderPage();
+ await screen.findByRole("checkbox", { name: "V-01" });
+ await userEvent.click(
+ screen.getByRole("button", { name: "Ernte speichern" }),
+ );
+
+ expect(
+ screen.getByText("Bitte mindestens ein Volk auswählen."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("group", { name: /Beteiligte Völker/ }),
+ ).toHaveFocus();
+ expect(createHarvest).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx
index 32a2a22..d58791d 100644
--- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx
@@ -12,9 +12,10 @@ import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } 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,
@@ -25,9 +26,30 @@ import {
} from "@/components/ui/select";
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 { ToggleChip } from "@/components/form/toggle-chip";
+import { StickySubmit } from "@/components/form/sticky-submit";
+import { DraftBanner } from "@/components/form/draft-banner";
+import {
+ HarvestFormValues,
+ initialHarvestValues,
+ toHarvestInput,
+} from "./harvest-form-values";
-function todayIsoDate(): string {
- return new Date().toISOString().slice(0, 10);
+type RequiredKey = "apiaryId" | "date" | "colonyIds";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["apiaryId", "date", "colonyIds"];
+
+function validate(values: HarvestFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.apiaryId) errors.apiaryId = "Bitte einen Bienenstand auswählen.";
+ if (!values.date) errors.date = "Bitte ein Datum angeben.";
+ if (values.colonyIds.length === 0) {
+ errors.colonyIds = "Bitte mindestens ein Volk auswählen.";
+ }
+ return errors;
}
export default function NewHarvestPage() {
@@ -35,21 +57,26 @@ export default function NewHarvestPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
- const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
- const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
+ const { data: apiaries, isLoading: apiariesLoading } = useQuery({
+ queryKey: ["apiaries"],
+ queryFn: () => fetchApiaries(),
+ });
+ const { data: colonies } = useQuery({
+ queryKey: ["colonies"],
+ queryFn: () => fetchColonies(),
+ });
- const [apiaryId, setApiaryId] = useState(searchParams.get("apiaryId") ?? "");
- const [date, setDate] = useState(todayIsoDate());
- const [colonyIds, setColonyIds] = useState([]);
- const [honeyType, setHoneyType] = useState("");
- const [honeySuperCount, setHoneySuperCount] = useState("");
- const [grossWeight, setGrossWeight] = useState("");
- const [netWeight, setNetWeight] = useState("");
- const [notes, setNotes] = useState("");
- const [error, setError] = useState(null);
+ const [values, setValues] = useState(() =>
+ initialHarvestValues(searchParams.get("apiaryId") ?? ""),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("harvest-new", values, dirty);
const apiaryColonies = (colonies ?? []).filter(
- (colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
+ (colony) =>
+ colony.apiaryId === values.apiaryId && colony.status === "ACTIVE",
);
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
const { data: withdrawalStatuses } = useQuery({
@@ -61,6 +88,33 @@ export default function NewHarvestPage() {
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
);
+ function set(
+ key: K,
+ value: HarvestFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
+
+ function selectApiary(apiaryId: string) {
+ // Colonies belong to one apiary - a new apiary starts a new selection.
+ setValues((current) => ({ ...current, apiaryId, colonyIds: [] }));
+ setDirty(true);
+ setFieldErrors((current) => ({ ...current, apiaryId: undefined }));
+ }
+
+ function toggleColony(colonyId: string, checked: boolean) {
+ set(
+ "colonyIds",
+ checked
+ ? [...values.colonyIds, colonyId]
+ : values.colonyIds.filter((id) => id !== colonyId),
+ );
+ }
+
// 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.
const [idempotencyKey] = useState(newIdempotencyKey);
@@ -68,49 +122,49 @@ export default function NewHarvestPage() {
mutationFn: (input: Parameters[0]) =>
createHarvest(input, { idempotencyKey }),
onSuccess: (harvest) => {
+ draft.clear();
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
- setError("Ernte konnte nicht gespeichert werden.");
+ setSubmitError(
+ errorMessage(mutationError, "Ernte konnte nicht gespeichert werden."),
+ );
return;
}
void queueHarvest(variables, idempotencyKey).then(() => {
- toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
+ draft.clear();
+ toast.info(
+ "Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.",
+ );
router.push("/harvests");
});
},
});
- function toggleColony(colonyId: string, checked: boolean) {
- setColonyIds((current) =>
- checked ? [...current, colonyId] : current.filter((id) => id !== colonyId),
- );
- }
-
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- if (!apiaryId) {
- setError("Bitte einen Bienenstand auswählen.");
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
return;
}
- if (colonyIds.length === 0) {
- setError("Bitte mindestens ein Volk auswählen.");
- return;
+ mutation.mutate(toHarvestInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
}
- mutation.mutate({
- apiaryId,
- date: new Date(date).toISOString(),
- colonyIds,
- honeyType: honeyType || undefined,
- honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
- grossWeight: grossWeight ? Number(grossWeight) : undefined,
- netWeight: netWeight ? Number(netWeight) : undefined,
- notes: notes || undefined,
- });
}
return (
@@ -118,141 +172,233 @@ export default function NewHarvestPage() {
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/queens/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/queens/new/page.spec.tsx
new file mode 100644
index 0000000..3ee3c7d
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/queens/new/page.spec.tsx
@@ -0,0 +1,77 @@
+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 NewQueenPage from "./page";
+import { createQueen, fetchQueens } from "@/lib/queens/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+}));
+vi.mock("@/lib/queens/api", () => ({
+ createQueen: vi.fn(),
+ fetchQueens: vi.fn(),
+}));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewQueenPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchQueens).mockResolvedValue([] as never);
+ vi.mocked(createQueen).mockReset();
+ });
+
+ it("marks only the code as required and explains the marking colour", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(1);
+ expect(screen.getByText(/Farbe zeigt das Schlupfjahr/)).toBeInTheDocument();
+ expect(screen.queryByLabelText(/Zucht\/Rasse/)).not.toBeInTheDocument();
+ });
+
+ it("proposes the marking colour from the hatch year and sends the queen", async () => {
+ vi.mocked(createQueen).mockResolvedValue({ id: "q-1" } as never);
+ renderPage();
+
+ await userEvent.type(screen.getByLabelText(/^Code/), "K-2026-03");
+ await userEvent.type(screen.getByLabelText(/Schlupfjahr/), "2026");
+ expect(screen.getByRole("radio", { name: "Weiß" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Königin anlegen" }),
+ );
+
+ const input = vi.mocked(createQueen).mock.calls[0][0];
+ expect(input).toMatchObject({
+ code: "K-2026-03",
+ hatchYear: 2026,
+ markingColor: "Weiß",
+ });
+ expect(input.motherQueenId).toBeUndefined();
+ expect(push).toHaveBeenCalledWith("/queens/q-1");
+ });
+
+ it("shows an inline error and focuses the code when it is missing", async () => {
+ renderPage();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Königin anlegen" }),
+ );
+
+ expect(screen.getByText("Bitte einen Code angeben.")).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Code/)).toHaveFocus();
+ expect(createQueen).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/queens/new/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/new/page.tsx
index c27a51f..0ae22d6 100644
--- a/ApiCore-Frontend/src/app/(app)/queens/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/queens/new/page.tsx
@@ -3,12 +3,14 @@
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
import { Crown } from "lucide-react";
import { createQueen, fetchQueens } from "@/lib/queens/api";
-import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
+import { useFormDraft } from "@/lib/forms/use-form-draft";
+import { errorMessage } from "@/lib/forms/error-message";
+import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -18,27 +20,56 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+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 {
+ MARKING_COLOR_OPTIONS,
+ QueenFormValues,
+ initialQueenValues,
+ toQueenInput,
+ withHatchYear,
+} from "./queen-form-values";
+
+type RequiredKey = "code";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["code"];
+const MIN_HATCH_YEAR = 2000;
+
+function validate(values: QueenFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.code.trim()) errors.code = "Bitte einen Code angeben.";
+ return errors;
+}
export default function NewQueenPage() {
const router = useRouter();
const queryClient = useQueryClient();
- const [code, setCode] = useState("");
- const [hatchYear, setHatchYear] = useState("");
- const [breed, setBreed] = useState("");
- const [origin, setOrigin] = useState("");
- const [markingColor, setMarkingColor] = useState("");
- const [markingNumber, setMarkingNumber] = useState("");
- const [motherQueenId, setMotherQueenId] = useState("");
- const [error, setError] = useState(null);
- function handleHatchYearChange(value: string) {
- setHatchYear(value);
- const year = Number(value);
- if (!markingColor && value && Number.isInteger(year)) {
- setMarkingColor(suggestMarkingColorForYear(year));
+ const [values, setValues] = useState(initialQueenValues);
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("queen-new", values, dirty);
+
+ function set(
+ key: K,
+ value: QueenFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
+ function handleHatchYearChange(hatchYear: string) {
+ setValues((current) => withHatchYear(current, hatchYear));
+ setDirty(true);
+ }
+
const { data: queens } = useQuery({
queryKey: ["queens"],
queryFn: () => fetchQueens(),
@@ -47,26 +78,39 @@ export default function NewQueenPage() {
const mutation = useMutation({
mutationFn: createQueen,
onSuccess: (queen) => {
+ draft.clear();
void queryClient.invalidateQueries({ queryKey: ["queens"] });
+ toast.success("Königin angelegt");
router.push(`/queens/${queen.id}`);
},
- onError: () => {
- setError("Königin konnte nicht angelegt werden.");
+ onError: (mutationError) => {
+ setSubmitError(
+ errorMessage(mutationError, "Königin konnte nicht angelegt werden."),
+ );
},
});
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- mutation.mutate({
- code,
- hatchYear: hatchYear ? Number(hatchYear) : undefined,
- breed: breed || undefined,
- origin: origin || undefined,
- markingColor: markingColor || undefined,
- markingNumber: markingNumber || undefined,
- motherQueenId: motherQueenId || undefined,
- });
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
+ return;
+ }
+ mutation.mutate(toQueenInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
}
return (
@@ -74,100 +118,209 @@ export default function NewQueenPage() {
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts b/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts
new file mode 100644
index 0000000..6b63c20
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts
@@ -0,0 +1,71 @@
+import type { createQueen } from "@/lib/queens/api";
+import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface QueenFormValues {
+ code: string;
+ hatchYear: string;
+ breed: string;
+ origin: string;
+ markingColor: string;
+ markingNumber: string;
+ motherQueenId: string;
+}
+
+/** The five colours of the international convention, in year order. */
+export const MARKING_COLOR_OPTIONS = ["Weiß", "Gelb", "Rot", "Grün", "Blau"];
+
+export function initialQueenValues(): QueenFormValues {
+ return {
+ code: "",
+ hatchYear: "",
+ breed: "",
+ origin: "",
+ markingColor: "",
+ markingNumber: "",
+ motherQueenId: "",
+ };
+}
+
+function suggestionFor(hatchYear: string): string | null {
+ // Only a complete year: typing "2026" must not lock in the colour for "2".
+ if (!/^\d{4}$/.test(hatchYear.trim())) return null;
+ return suggestMarkingColorForYear(Number(hatchYear));
+}
+
+/**
+ * Sets the hatch year and proposes the matching marking colour - unless a
+ * colour was chosen by hand (anything other than the previous proposal).
+ */
+export function withHatchYear(
+ values: QueenFormValues,
+ hatchYear: string,
+): QueenFormValues {
+ const previousSuggestion = suggestionFor(values.hatchYear);
+ const chosenByHand =
+ values.markingColor !== "" && values.markingColor !== previousSuggestion;
+ const suggestion = suggestionFor(hatchYear);
+ return {
+ ...values,
+ hatchYear,
+ markingColor:
+ chosenByHand || !suggestion ? values.markingColor : suggestion,
+ };
+}
+
+const textOrUndefined = (value: string) =>
+ value.trim() ? value.trim() : undefined;
+
+export function toQueenInput(
+ values: QueenFormValues,
+): Parameters[0] {
+ return {
+ code: values.code.trim(),
+ hatchYear: values.hatchYear.trim() ? Number(values.hatchYear) : undefined,
+ breed: textOrUndefined(values.breed),
+ origin: textOrUndefined(values.origin),
+ markingColor: textOrUndefined(values.markingColor),
+ markingNumber: textOrUndefined(values.markingNumber),
+ motherQueenId: values.motherQueenId || undefined,
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx
new file mode 100644
index 0000000..23ffb2a
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx
@@ -0,0 +1,93 @@
+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 NewSwarmControlCheckPage from "./page";
+import { createSwarmControlCheck } from "@/lib/swarm-control/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/swarm-control/api", () => ({
+ createSwarmControlCheck: vi.fn(),
+}));
+vi.mock("@/lib/offline/db", () => ({ queueSwarmControlCheck: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewSwarmControlCheckPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE" },
+ ] as never);
+ vi.mocked(createSwarmControlCheck).mockReset();
+ });
+
+ it("marks colony and date as required and keeps notes folded", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(2);
+ expect(screen.queryByLabelText(/Notizen/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Weitere Angaben/ }),
+ ).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("records cells, mood and action with single taps", async () => {
+ vi.mocked(createSwarmControlCheck).mockResolvedValue({
+ id: "s-1",
+ colonyId: "colony-1",
+ } as never);
+ renderPage();
+
+ await userEvent.type(screen.getByLabelText(/Weiselzellen gefunden/), "3");
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: /bereits verdeckelt/ }),
+ );
+ await userEvent.click(screen.getByRole("radio", { name: "Hoch" }));
+ await userEvent.click(
+ screen.getByRole("radio", { name: "Kunstschwarm gebildet" }),
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Kontrolle speichern" }),
+ );
+
+ const [input, options] = vi.mocked(createSwarmControlCheck).mock.calls[0];
+ expect(input).toMatchObject({
+ colonyId: "colony-1",
+ queenCellsFound: 3,
+ queenCellsCapped: true,
+ swarmMood: "HIGH",
+ actionTaken: "ARTIFICIAL_SWARM",
+ });
+ expect(input.nextCheckDate).toBeUndefined();
+ expect(options?.idempotencyKey).toBeTruthy();
+ expect(push).toHaveBeenCalledWith("/colonies/colony-1");
+ });
+
+ it("shows an inline error and focuses the date when it is missing", async () => {
+ renderPage();
+ await userEvent.clear(screen.getByLabelText(/^Datum/));
+ await userEvent.click(
+ screen.getByRole("button", { name: "Kontrolle speichern" }),
+ );
+
+ expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
+ expect(createSwarmControlCheck).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx
index 9540486..df60de1 100644
--- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx
@@ -7,14 +7,19 @@ import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
-import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
+import {
+ SWARM_ACTION_LABELS,
+ SWARM_MOOD_LABELS,
+} from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
import { ApiError } from "@/lib/auth/api-client";
import { queueSwarmControlCheck } 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 { cn } from "@/lib/utils";
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,
@@ -25,9 +30,38 @@ import {
} from "@/components/ui/select";
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 { ToggleChip } from "@/components/form/toggle-chip";
+import { StickySubmit } from "@/components/form/sticky-submit";
+import { DraftBanner } from "@/components/form/draft-banner";
+import {
+ SwarmControlFormValues,
+ initialSwarmControlValues,
+ toSwarmControlInput,
+} from "./swarm-control-form-values";
-function todayIsoDate(): string {
- return new Date().toISOString().slice(0, 10);
+type RequiredKey = "colonyId" | "date";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
+const SWARM_MOODS = Object.keys(SWARM_MOOD_LABELS) as SwarmMood[];
+const SWARM_ACTIONS = Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[];
+
+function validate(values: SwarmControlFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
+ if (!values.date) errors.date = "Bitte ein Datum angeben.";
+ return errors;
+}
+
+function tileClass(selected: boolean) {
+ return 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",
+ );
}
export default function NewSwarmControlCheckPage() {
@@ -35,21 +69,31 @@ export default function NewSwarmControlCheckPage() {
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 [queenCellsFound, setQueenCellsFound] = useState("");
- const [queenCellsCapped, setQueenCellsCapped] = useState(false);
- const [swarmMood, setSwarmMood] = useState("NONE");
- const [actionTaken, setActionTaken] = useState("NONE");
- const [nextCheckDate, setNextCheckDate] = useState("");
- const [notes, setNotes] = useState("");
- const [error, setError] = useState(null);
+ const [values, setValues] = useState(() =>
+ initialSwarmControlValues(searchParams.get("colonyId") ?? ""),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("swarm-control-new", values, dirty);
+
+ function set(
+ key: K,
+ value: SwarmControlFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
// 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.
@@ -58,6 +102,7 @@ export default function NewSwarmControlCheckPage() {
mutationFn: (input: Parameters[0]) =>
createSwarmControlCheck(input, { idempotencyKey }),
onSuccess: (check) => {
+ draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
@@ -66,11 +111,19 @@ export default function NewSwarmControlCheckPage() {
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
- setError("Schwarmkontrolle konnte nicht gespeichert werden.");
+ setSubmitError(
+ errorMessage(
+ mutationError,
+ "Schwarmkontrolle konnte nicht gespeichert werden.",
+ ),
+ );
return;
}
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
- toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.");
+ draft.clear();
+ toast.info(
+ "Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
+ );
router.push(`/colonies/${variables.colonyId}`);
});
},
@@ -78,21 +131,25 @@ export default function NewSwarmControlCheckPage() {
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- if (!colonyId) {
- setError("Bitte ein Volk auswählen.");
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
return;
}
- mutation.mutate({
- colonyId,
- date: new Date(date).toISOString(),
- queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
- queenCellsCapped,
- swarmMood,
- actionTaken,
- nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
- notes: notes || undefined,
- });
+ mutation.mutate(toSwarmControlInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
}
return (
@@ -100,124 +157,224 @@ export default function NewSwarmControlCheckPage() {
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/swarm-control-form-values.ts b/ApiCore-Frontend/src/app/(app)/swarm-control/new/swarm-control-form-values.ts
new file mode 100644
index 0000000..b1213f6
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/swarm-control-form-values.ts
@@ -0,0 +1,52 @@
+import type { createSwarmControlCheck } from "@/lib/swarm-control/api";
+import type { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface SwarmControlFormValues {
+ colonyId: string;
+ date: string;
+ queenCellsFound: string;
+ queenCellsCapped: boolean;
+ swarmMood: SwarmMood;
+ actionTaken: SwarmControlAction;
+ nextCheckDate: string;
+ notes: string;
+}
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialSwarmControlValues(
+ colonyId: string,
+): SwarmControlFormValues {
+ return {
+ colonyId,
+ date: todayIsoDate(),
+ queenCellsFound: "",
+ queenCellsCapped: false,
+ swarmMood: "NONE",
+ actionTaken: "NONE",
+ nextCheckDate: "",
+ notes: "",
+ };
+}
+
+export function toSwarmControlInput(
+ values: SwarmControlFormValues,
+): Parameters[0] {
+ return {
+ colonyId: values.colonyId,
+ date: new Date(values.date).toISOString(),
+ queenCellsFound: values.queenCellsFound.trim()
+ ? Number(values.queenCellsFound)
+ : undefined,
+ queenCellsCapped: values.queenCellsCapped,
+ swarmMood: values.swarmMood,
+ actionTaken: values.actionTaken,
+ nextCheckDate: values.nextCheckDate
+ ? new Date(values.nextCheckDate).toISOString()
+ : undefined,
+ notes: values.notes.trim() ? values.notes.trim() : undefined,
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx
new file mode 100644
index 0000000..b74d498
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx
@@ -0,0 +1,73 @@
+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 NewTaskPage from "./page";
+import { createTask } from "@/lib/tasks/api";
+import { fetchUsers } from "@/lib/users/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+}));
+vi.mock("@/lib/users/api", () => ({ fetchUsers: vi.fn() }));
+vi.mock("@/lib/tasks/api", () => ({ createTask: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewTaskPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchUsers).mockResolvedValue([] as never);
+ vi.mocked(createTask).mockReset();
+ });
+
+ it("marks only the title as required and keeps the rest folded", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(1);
+ expect(screen.queryByLabelText(/Beschreibung/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Weitere Angaben/ }),
+ ).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("sends the task with title and tapped priority", async () => {
+ vi.mocked(createTask).mockResolvedValue({ id: "t-1" } as never);
+ renderPage();
+
+ await userEvent.type(screen.getByLabelText(/^Titel/), "Honigraum aufsetzen");
+ await userEvent.click(screen.getByRole("radio", { name: "Dringend" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: "Aufgabe anlegen" }),
+ );
+
+ const input = vi.mocked(createTask).mock.calls[0][0];
+ expect(input).toMatchObject({
+ title: "Honigraum aufsetzen",
+ priority: "URGENT",
+ });
+ expect(input.dueDate).toBeUndefined();
+ expect(push).toHaveBeenCalledWith("/tasks");
+ });
+
+ it("shows an inline error and focuses the title when it is missing", async () => {
+ renderPage();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Aufgabe anlegen" }),
+ );
+
+ expect(screen.getByText("Bitte einen Titel angeben.")).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Titel/)).toHaveFocus();
+ expect(createTask).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx
index 8b0f0b7..4dc0f84 100644
--- a/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx
@@ -6,12 +6,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { createTask } from "@/lib/tasks/api";
-import { TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api";
+import { useFormDraft } from "@/lib/forms/use-form-draft";
+import { errorMessage } from "@/lib/forms/error-message";
+import { cn } from "@/lib/utils";
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,
@@ -22,16 +23,36 @@ import {
} from "@/components/ui/select";
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 {
+ TaskFormValues,
+ initialTaskValues,
+ toTaskInput,
+} from "./task-form-values";
+
+type RequiredKey = "title";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["title"];
+
+function validate(values: TaskFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.title.trim()) errors.title = "Bitte einen Titel angeben.";
+ return errors;
+}
export default function NewTaskPage() {
const router = useRouter();
const queryClient = useQueryClient();
- const [title, setTitle] = useState("");
- const [description, setDescription] = useState("");
- const [priority, setPriority] = useState("NORMAL");
- const [dueDate, setDueDate] = useState("");
- const [assignedUserId, setAssignedUserId] = useState("");
- const [error, setError] = useState(null);
+
+ const [values, setValues] = useState(initialTaskValues);
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("task-new", values, dirty);
// Assigning a task requires users:read, which not every role has - if the
// request fails, the assignee field below is simply omitted.
@@ -41,28 +62,53 @@ export default function NewTaskPage() {
retry: false,
});
+ function set(
+ key: K,
+ value: TaskFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
+
const mutation = useMutation({
mutationFn: createTask,
onSuccess: () => {
+ draft.clear();
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Aufgabe angelegt");
router.push("/tasks");
},
- onError: () => {
- setError("Aufgabe konnte nicht angelegt werden.");
+ onError: (mutationError) => {
+ setSubmitError(
+ errorMessage(mutationError, "Aufgabe konnte nicht angelegt werden."),
+ );
},
});
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- mutation.mutate({
- title,
- description: description || undefined,
- priority,
- dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
- assignedUserId: assignedUserId || undefined,
- });
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
+ return;
+ }
+ mutation.mutate(toTaskInput(values));
+ }
+
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
}
return (
@@ -70,92 +116,158 @@ export default function NewTaskPage() {
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts b/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts
new file mode 100644
index 0000000..a069321
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts
@@ -0,0 +1,37 @@
+import type { createTask } from "@/lib/tasks/api";
+import type { TaskPriority } from "@/lib/tasks/types";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface TaskFormValues {
+ title: string;
+ description: string;
+ priority: TaskPriority;
+ dueDate: string;
+ assignedUserId: string;
+}
+
+export function initialTaskValues(): TaskFormValues {
+ return {
+ title: "",
+ description: "",
+ priority: "NORMAL",
+ dueDate: "",
+ assignedUserId: "",
+ };
+}
+
+export function toTaskInput(
+ values: TaskFormValues,
+): Parameters[0] {
+ return {
+ title: values.title.trim(),
+ description: values.description.trim()
+ ? values.description.trim()
+ : undefined,
+ priority: values.priority,
+ dueDate: values.dueDate
+ ? new Date(values.dueDate).toISOString()
+ : undefined,
+ assignedUserId: values.assignedUserId || undefined,
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx
new file mode 100644
index 0000000..a543f34
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx
@@ -0,0 +1,88 @@
+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 NewVarroaMeasurementPage from "./page";
+import { createVarroaMeasurement } from "@/lib/varroa/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/varroa/api", () => ({ createVarroaMeasurement: vi.fn() }));
+vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewVarroaMeasurementPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE" },
+ ] as never);
+ vi.mocked(createVarroaMeasurement).mockReset();
+ });
+
+ it("marks colony and date as required and explains the daily mite fall", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(2);
+ expect(screen.getByText(/Milbenfall pro Tag/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("radio", { name: "Natürlicher Milbenfall" }),
+ ).toHaveAttribute("aria-checked", "true");
+ expect(screen.queryByLabelText(/Einschätzung/)).not.toBeInTheDocument();
+ });
+
+ it("sends the measurement with method and numbers", async () => {
+ vi.mocked(createVarroaMeasurement).mockResolvedValue({
+ id: "v-1",
+ colonyId: "colony-1",
+ } as never);
+ renderPage();
+
+ await userEvent.click(
+ screen.getByRole("radio", { name: "Puderzuckermethode" }),
+ );
+ await userEvent.type(screen.getByLabelText(/Milbenzahl/), "12");
+ await userEvent.type(screen.getByLabelText(/Zeitraum/), "5");
+ await userEvent.click(
+ screen.getByRole("button", { name: "Messung speichern" }),
+ );
+
+ const [input, options] = vi.mocked(createVarroaMeasurement).mock.calls[0];
+ expect(input).toMatchObject({
+ colonyId: "colony-1",
+ method: "POWDERED_SUGAR",
+ miteCount: 12,
+ durationMinutes: 5,
+ });
+ expect(input.assessment).toBeUndefined();
+ expect(options?.idempotencyKey).toBeTruthy();
+ expect(push).toHaveBeenCalledWith("/colonies/colony-1");
+ });
+
+ it("shows an inline error and focuses the date when it is missing", async () => {
+ renderPage();
+ await userEvent.clear(screen.getByLabelText(/^Datum/));
+ await userEvent.click(
+ screen.getByRole("button", { name: "Messung speichern" }),
+ );
+
+ expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
+ expect(createVarroaMeasurement).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx
index 1fe5f23..e4888d8 100644
--- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx
@@ -7,14 +7,15 @@ import { toast } from "sonner";
import { Bug } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createVarroaMeasurement } from "@/lib/varroa/api";
-import { VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueVarroaMeasurement } 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 { cn } from "@/lib/utils";
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,
@@ -25,9 +26,27 @@ import {
} from "@/components/ui/select";
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 {
+ VARROA_METHOD_HINTS,
+ VarroaFormValues,
+ initialVarroaValues,
+ toVarroaInput,
+} from "./varroa-form-values";
-function todayIsoDate(): string {
- return new Date().toISOString().slice(0, 10);
+type RequiredKey = "colonyId" | "date";
+type FieldErrors = Partial>;
+
+const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
+
+function validate(values: VarroaFormValues): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
+ if (!values.date) errors.date = "Bitte ein Datum angeben.";
+ return errors;
}
export default function NewVarroaMeasurementPage() {
@@ -35,20 +54,31 @@ export default function NewVarroaMeasurementPage() {
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 [method, setMethod] = useState("NATURAL_MITE_FALL");
- const [miteCount, setMiteCount] = useState("");
- const [durationMinutes, setDurationMinutes] = useState("");
- const [assessment, setAssessment] = useState("");
- const [notes, setNotes] = useState("");
- const [error, setError] = useState(null);
+ const [values, setValues] = useState(() =>
+ initialVarroaValues(searchParams.get("colonyId") ?? ""),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("varroa-new", values, dirty);
+
+ function set(
+ key: K,
+ value: VarroaFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if ((REQUIRED_KEYS as string[]).includes(key)) {
+ setFieldErrors((current) => ({ ...current, [key]: undefined }));
+ }
+ }
// 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.
@@ -57,6 +87,7 @@ export default function NewVarroaMeasurementPage() {
mutationFn: (input: Parameters[0]) =>
createVarroaMeasurement(input, { idempotencyKey }),
onSuccess: (measurement) => {
+ draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
@@ -65,11 +96,19 @@ export default function NewVarroaMeasurementPage() {
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
- setError("Varroamessung konnte nicht gespeichert werden.");
+ setSubmitError(
+ errorMessage(
+ mutationError,
+ "Varroamessung konnte nicht gespeichert werden.",
+ ),
+ );
return;
}
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
- toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.");
+ draft.clear();
+ toast.info(
+ "Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
+ );
router.push(`/colonies/${variables.colonyId}`);
});
},
@@ -77,126 +116,240 @@ export default function NewVarroaMeasurementPage() {
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- if (!colonyId) {
- setError("Bitte ein Volk auswählen.");
+ setSubmitError(null);
+ const errors = validate(values);
+ setFieldErrors(errors);
+ const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
+ if (firstInvalid) {
+ document
+ .querySelector(`[data-field="${firstInvalid}"]`)
+ ?.focus();
return;
}
- mutation.mutate({
- colonyId,
- date: new Date(date).toISOString(),
- method,
- miteCount: miteCount ? Number(miteCount) : undefined,
- durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
- assessment: assessment || undefined,
- notes: notes || undefined,
- });
+ mutation.mutate(toVarroaInput(values));
}
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
+ }
+
+ const isNaturalFall = values.method === "NATURAL_MITE_FALL";
+
return (
+ {draft.pending ? (
+
+ ) : null}
-
diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts b/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts
new file mode 100644
index 0000000..4ae11e1
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts
@@ -0,0 +1,60 @@
+import type { createVarroaMeasurement } from "@/lib/varroa/api";
+import type { VarroaMethod } from "@/lib/varroa/types";
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface VarroaFormValues {
+ colonyId: string;
+ date: string;
+ method: VarroaMethod;
+ miteCount: string;
+ durationMinutes: string;
+ assessment: string;
+ notes: string;
+}
+
+/** One plain sentence per method: what is counted and how to read it. */
+export const VARROA_METHOD_HINTS: Record = {
+ NATURAL_MITE_FALL:
+ "Milben auf der Windel unter dem Gitterboden zählen. Gezählte Milben durch die Anzahl Tage teilen – nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
+ POWDERED_SUGAR:
+ "Bienenprobe mit Puderzucker bestäuben und schütteln, herausgefallene Milben zählen.",
+ WASHING_METHOD:
+ "Bienenprobe auswaschen und die abgelösten Milben zählen.",
+ DRONE_BROOD_CONTROL:
+ "Entnommene Drohnenbrut öffnen und befallene Zellen bzw. Milben zählen.",
+};
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialVarroaValues(colonyId: string): VarroaFormValues {
+ return {
+ colonyId,
+ date: todayIsoDate(),
+ method: "NATURAL_MITE_FALL",
+ miteCount: "",
+ durationMinutes: "",
+ assessment: "",
+ notes: "",
+ };
+}
+
+const textOrUndefined = (value: string) =>
+ value.trim() ? value.trim() : undefined;
+const numberOrUndefined = (value: string) =>
+ value.trim() ? Number(value) : undefined;
+
+export function toVarroaInput(
+ values: VarroaFormValues,
+): Parameters[0] {
+ return {
+ colonyId: values.colonyId,
+ date: new Date(values.date).toISOString(),
+ method: values.method,
+ miteCount: numberOrUndefined(values.miteCount),
+ durationMinutes: numberOrUndefined(values.durationMinutes),
+ assessment: textOrUndefined(values.assessment),
+ notes: textOrUndefined(values.notes),
+ };
+}