From ed0a0005349a9428a6a585d8471104d717d16579 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 12:25:24 +0200 Subject: [PATCH] feat(ui): remaining capture forms with sections, tap choices and drafts Varroa, swarm control, harvest, extraction, bottling, task and queen forms use the form kit: required fields marked, inline errors with focus on the first problem, rare fields folded, drafts, save in thumb reach. Single choices (varroa method, swarm mood/action, priority, marking colour) are large tap buttons; colonies and harvests are picked as tiles. Plain hints: per-day mite fall vs. threshold, Brutto/Netto, water content, MHD and lot number, marking colour by year. Fixed on the way: the queen form suggested the marking colour from the first digit typed ("2" -> Gelb for 2026); it now waits for a full year. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bottling/new/bottling-form-values.ts | 42 ++ .../src/app/(app)/bottling/new/page.spec.tsx | 89 ++++ .../src/app/(app)/bottling/new/page.tsx | 408 +++++++++++---- .../extractions/new/extraction-form-values.ts | 46 ++ .../app/(app)/extractions/new/page.spec.tsx | 83 +++ .../src/app/(app)/extractions/new/page.tsx | 350 ++++++++----- .../(app)/harvests/new/harvest-form-values.ts | 50 ++ .../src/app/(app)/harvests/new/page.spec.tsx | 93 ++++ .../src/app/(app)/harvests/new/page.tsx | 486 ++++++++++++------ .../src/app/(app)/queens/new/page.spec.tsx | 77 +++ .../src/app/(app)/queens/new/page.tsx | 383 +++++++++----- .../app/(app)/queens/new/queen-form-values.ts | 71 +++ .../app/(app)/swarm-control/new/page.spec.tsx | 93 ++++ .../src/app/(app)/swarm-control/new/page.tsx | 437 +++++++++++----- .../new/swarm-control-form-values.ts | 52 ++ .../src/app/(app)/tasks/new/page.spec.tsx | 73 +++ .../src/app/(app)/tasks/new/page.tsx | 308 +++++++---- .../app/(app)/tasks/new/task-form-values.ts | 37 ++ .../src/app/(app)/varroa/new/page.spec.tsx | 88 ++++ .../src/app/(app)/varroa/new/page.tsx | 395 +++++++++----- .../(app)/varroa/new/varroa-form-values.ts | 60 +++ 21 files changed, 2846 insertions(+), 875 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/new/bottling-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/queens/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/swarm-control/new/swarm-control-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts create mode 100644 ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts 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} -
-
- - -
-
- - set("honeyBatchId", value ?? "")} + > + + + + + {honeyBatches?.map((honeyBatch) => ( + + {honeyBatch.batchNumber} + {honeyBatch.amount !== null + ? ` · ${honeyBatch.amount} kg` + : ""} + + ))} + + + )} + + + {({ id, describedBy, invalid }) => ( + set("date", event.target.value)} + /> + )} + + + + + - - - - - {products?.map((product) => ( - - {product.name} - - ))} - - -
-
- - ( + set("jarSize", event.target.value)} + /> + )} + + setDate(event.target.value)} - /> -
-
- - + {({ id, describedBy, invalid }) => ( + set("jarCount", event.target.value)} + /> + )} + + setJarSize(event.target.value)} - /> -
-
- - setJarCount(event.target.value)} - /> -
-
- - setBestBeforeDate(event.target.value)} - /> -
-
- - setLotNumber(event.target.value)} - /> -
- {error ? ( -

- {error} -

- ) : null} - + error={fieldErrors.bestBeforeDate} + hint="Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen." + > + {({ id, describedBy, invalid }) => ( + + set("bestBeforeDate", event.target.value) + } + /> + )} + + + + + + {({ id }) => ( + + )} + + + {({ id, describedBy }) => ( + set("lotNumber", event.target.value)} + /> + )} + + + + + {submitError} +

+ ) : 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} -
-
- - + + + {({ id, describedBy, invalid }) => ( + set("date", event.target.value)} + /> + )} + + setDate(event.target.value)} - /> -
-
- - {harvests?.length === 0 ? ( -

Noch keine Ernten vorhanden.

- ) : null} -
- {harvests?.map((harvest) => ( -
-
- - setAmount(event.target.value)} - /> -
-
- - setRoom(event.target.value)} - /> -
-
- - setWaterContent(event.target.value)} - /> -
-
- -