From ced65314ba44cea9d6316436c196b0e75216ec4d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 14:20:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Bearbeiten-Dialoge=20f=C3=BCr=20Charge,?= =?UTF-8?q?=20Ernte,=20Abf=C3=BCllung=20und=20Produkt=20mit=20dem=20Formul?= =?UTF-8?q?ar-Kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felder mit Pflicht-Markierung und Fehler direkt am Feld, Fokus auf das erste Problemfeld, Serverfehler über dem Speichern-Knopf, Fokus nach dem Schließen zurück auf „Bearbeiten“; Ernte-Völker als Kacheln; Bearbeiten offline gesperrt wie bei den Volks-Dialogen (Änderungen werden nicht vorgemerkt). Gemeinsame Teile DialogSubmitBar und EditTriggerButton nach components/form. Refs #9 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../[id]/bottling-edit-dialog.spec.tsx | 168 +++++++++ .../bottling/[id]/bottling-edit-dialog.tsx | 295 ++++++++++----- .../colonies/[id]/feeding-edit-dialog.tsx | 2 +- .../colonies/[id]/inspection-edit-dialog.tsx | 2 +- .../[id]/swarm-control-edit-dialog.tsx | 2 +- .../colonies/[id]/treatment-edit-dialog.tsx | 2 +- .../colonies/[id]/varroa-edit-dialog.tsx | 2 +- .../[id]/harvest-edit-dialog.spec.tsx | 95 +++++ .../harvests/[id]/harvest-edit-dialog.tsx | 279 ++++++++------ .../[id]/honey-batch-edit-dialog.spec.tsx | 145 ++++++++ .../[id]/honey-batch-edit-dialog.tsx | 296 +++++++++------ .../products/edit-product-dialog.spec.tsx | 81 +++- .../(app)/products/edit-product-dialog.tsx | 348 +++++++++--------- .../(app)/queens/[id]/queen-evaluations.tsx | 2 +- .../form}/edit-dialog-parts.spec.tsx | 2 +- .../form}/edit-dialog-parts.tsx | 0 16 files changed, 1222 insertions(+), 499 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.spec.tsx rename ApiCore-Frontend/src/{app/(app)/colonies/[id] => components/form}/edit-dialog-parts.spec.tsx (97%) rename ApiCore-Frontend/src/{app/(app)/colonies/[id] => components/form}/edit-dialog-parts.tsx (100%) diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.spec.tsx new file mode 100644 index 0000000..e19857d --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.spec.tsx @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ApiError } from "@/lib/auth/api-client"; +import { updateBottlingBatch } from "@/lib/bottling/api"; +import type { BottlingBatch } from "@/lib/bottling/types"; +import { BottlingEditDialog } from "./bottling-edit-dialog"; + +vi.mock("@/lib/bottling/api", () => ({ updateBottlingBatch: vi.fn() })); +vi.mock("@/lib/honey-batches/api", () => ({ + fetchHoneyBatches: vi.fn(async () => [{ id: "hb-1", batchNumber: "H-1" }]), +})); +vi.mock("@/lib/products/api", () => ({ + fetchProducts: vi.fn(async () => [{ id: "p-1", name: "Frühtracht" }]), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const BOTTLING = { + id: "b-1", + honeyBatchId: "hb-1", + productId: "p-1", + date: "2026-08-01T00:00:00.000Z", + jarSize: 500, + jarCount: 24, + totalAmount: 12, + bestBeforeDate: "2028-08-01T00:00:00.000Z", + lotNumber: "L-26-01", + createdAt: "", + remainingCount: 24, + honeyBatch: { id: "hb-1", batchNumber: "H-1" }, + product: { id: "p-1", name: "Frühtracht" }, +} as BottlingBatch; + +async function openDialog(bottling: BottlingBatch = BOTTLING) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ })); + return screen.findByRole("dialog"); +} + +const save = () => userEvent.click(screen.getByRole("button", { name: "Speichern" })); + +describe("BottlingEditDialog", () => { + beforeEach(() => { + vi.mocked(updateBottlingBatch).mockReset(); + vi.mocked(updateBottlingBatch).mockResolvedValue(BOTTLING); + }); + + it("marks what is required and what is optional, in 44 px fields", async () => { + const dialog = await openDialog(); + + expect(within(dialog).getByLabelText(/^Glasgröße \(g\)/)).toHaveClass("h-11"); + expect(within(dialog).getByLabelText(/^Mindesthaltbarkeitsdatum/).closest("div")).toHaveTextContent("Pflicht"); + expect(within(dialog).getByLabelText(/^Produkt/).closest("div")).toHaveTextContent("optional"); + }); + + it("sends the unchanged bottling with the same payload as before", async () => { + await openDialog(); + await save(); + + await waitFor(() => expect(updateBottlingBatch).toHaveBeenCalledTimes(1)); + expect(vi.mocked(updateBottlingBatch).mock.calls[0]).toEqual([ + "b-1", + { + honeyBatchId: "hb-1", + date: expect.stringContaining("2026-08-01"), + bestBeforeDate: expect.stringContaining("2028-08-01"), + jarSize: 500, + jarCount: 24, + lotNumber: "L-26-01", + productId: "p-1", + }, + ]); + }); + + it("clears the product assignment with null", async () => { + await openDialog(); + await userEvent.click(screen.getByLabelText(/^Produkt/)); + await userEvent.click(await screen.findByRole("option", { name: "Kein Produkt" })); + await save(); + + await waitFor(() => expect(updateBottlingBatch).toHaveBeenCalled()); + expect(vi.mocked(updateBottlingBatch).mock.calls[0][1]).toMatchObject({ productId: null }); + }); + + it("an empty lot number is explained at the field, focused, and nothing is sent", async () => { + await openDialog(); + const lot = screen.getByLabelText(/^Los-\/Chargennummer/); + await userEvent.clear(lot); + await save(); + + expect(screen.getByText("Bitte eine Los-/Chargennummer eingeben.")).toBeInTheDocument(); + expect(lot).toHaveFocus(); + expect(updateBottlingBatch).not.toHaveBeenCalled(); + }); + + it.each([ + ["Datum", "Bitte ein Datum angeben."], + ["Mindesthaltbarkeitsdatum", "Bitte ein Mindesthaltbarkeitsdatum angeben."], + ])("an emptied %s is explained at the field", async (label, message) => { + await openDialog(); + const field = screen.getByLabelText(new RegExp(`^${label}`)); + await userEvent.clear(field); + await save(); + + expect(screen.getByText(message)).toBeInTheDocument(); + expect(field).toHaveFocus(); + expect(updateBottlingBatch).not.toHaveBeenCalled(); + }); + + it.each(["0", "-5", "2.5"])("refuses a jar count of %s", async (bad) => { + await openDialog(); + const field = screen.getByLabelText(/^Anzahl Gläser/); + await userEvent.clear(field); + await userEvent.type(field, bad); + await save(); + + expect(screen.getByText("Bitte eine ganze Zahl ab 1 angeben.")).toBeInTheDocument(); + expect(field).toHaveFocus(); + expect(updateBottlingBatch).not.toHaveBeenCalled(); + }); + + it.each(["0", "-1"])("refuses a jar size of %s", async (bad) => { + await openDialog(); + const field = screen.getByLabelText(/^Glasgröße \(g\)/); + await userEvent.clear(field); + await userEvent.type(field, bad); + await save(); + + expect(screen.getByText("Bitte ganze Gramm ab 1 angeben.")).toBeInTheDocument(); + expect(updateBottlingBatch).not.toHaveBeenCalled(); + }); + + it("shows the server's explanation above the save button and keeps the dialog open", async () => { + vi.mocked(updateBottlingBatch).mockRejectedValue(new ApiError(400, "Zu wenig Honig in der Charge.")); + const dialog = await openDialog(); + await save(); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Zu wenig Honig in der Charge."); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("starts from the saved values each time it opens", async () => { + await openDialog(); + const lot = screen.getByLabelText(/^Los-\/Chargennummer/); + await userEvent.clear(lot); + await userEvent.type(lot, "anders"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ })); + + expect(await screen.findByLabelText(/^Los-\/Chargennummer/)).toHaveValue("L-26-01"); + }); + + it("closes after saving and puts the focus back on the Bearbeiten button", async () => { + await openDialog(); + await save(); + + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByRole("button", { name: /Bearbeiten/ })).toHaveFocus(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx index e1eee22..96c0d7d 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx @@ -1,18 +1,22 @@ "use client"; import { FormEvent, useState } from "react"; -import { Pencil } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { updateBottlingBatch } from "@/lib/bottling/api"; import { BottlingBatch } from "@/lib/bottling/types"; import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchProducts } from "@/lib/products/api"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; import { RecordSelect } from "@/components/form/record-select"; +import { + DATE_REQUIRED_MESSAGE, + DialogSubmitBar, + EditTriggerButton, +} from "@/components/form/edit-dialog-parts"; import { emptyToNull } from "@/lib/forms/empty-to-null"; +import { errorMessage } from "@/lib/forms/error-message"; import { Dialog, DialogContent, @@ -22,6 +26,45 @@ import { } from "@/components/ui/dialog"; import { toApiDate } from "@/lib/dates"; +const FIELD_IDS = { + date: "bottling-edit-date", + bestBeforeDate: "bottling-edit-best-before", + jarSize: "bottling-edit-jar-size", + jarCount: "bottling-edit-jar-count", + lotNumber: "bottling-edit-lot", +} as const; +const FIELD_ORDER = ["date", "bestBeforeDate", "jarSize", "jarCount", "lotNumber"] as const; + +type FieldErrors = Partial>; + +const BEST_BEFORE_REQUIRED = "Bitte ein Mindesthaltbarkeitsdatum angeben."; +const JAR_SIZE_PROBLEM = "Bitte ganze Gramm ab 1 angeben."; +const JAR_COUNT_PROBLEM = "Bitte eine ganze Zahl ab 1 angeben."; +const LOT_NUMBER_REQUIRED = "Bitte eine Los-/Chargennummer eingeben."; + +/** A whole number from 1 up - what the number fields enforced natively (min 1, step 1). */ +function isWholeFromOne(value: string): boolean { + if (value.trim() === "") return false; + const number = Number(value); + return Number.isInteger(number) && number >= 1; +} + +function validate(values: { + date: string; + bestBeforeDate: string; + jarSize: string; + jarCount: string; + lotNumber: string; +}): FieldErrors { + return { + date: values.date ? undefined : DATE_REQUIRED_MESSAGE, + bestBeforeDate: values.bestBeforeDate ? undefined : BEST_BEFORE_REQUIRED, + jarSize: isWholeFromOne(values.jarSize) ? undefined : JAR_SIZE_PROBLEM, + jarCount: isWholeFromOne(values.jarCount) ? undefined : JAR_COUNT_PROBLEM, + lotNumber: values.lotNumber.trim() ? undefined : LOT_NUMBER_REQUIRED, + }; +} + export function BottlingEditDialog({ bottlingBatch, }: { @@ -31,14 +74,7 @@ export function BottlingEditDialog({ return ( - - - Bearbeiten - - } - /> + } /> Abfüllung bearbeiten @@ -75,6 +111,7 @@ function BottlingEditForm({ // "" = "Kein Produkt" - choosing it clears the assignment again. const [productId, setProductId] = useState(bottlingBatch.productId ?? ""); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState({}); const { data: honeyBatches } = useQuery({ queryKey: ["honey-batches"], @@ -127,107 +164,161 @@ function BottlingEditForm({ void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); onSaved(); }, - onError: (mutationError: Error) => { - const message = - mutationError.message || "Abfüllung konnte nicht gespeichert werden"; - setError(message); - toast.error(message); - }, + onError: (mutationError) => + setError(errorMessage(mutationError, "Abfüllung konnte nicht gespeichert werden.")), }); + function clearFieldError(key: keyof FieldErrors) { + setFieldErrors((current) => ({ ...current, [key]: undefined })); + } + function handleSubmit(event: FormEvent) { event.preventDefault(); setError(null); + const errors = validate({ date, bestBeforeDate, jarSize, jarCount, lotNumber }); + setFieldErrors(errors); + const firstInvalid = FIELD_ORDER.find((key) => errors[key]); + if (firstInvalid) { + document.getElementById(FIELD_IDS[firstInvalid])?.focus(); + return; + } mutation.mutate(); } return ( -
-
- - honeyBatch.batchNumber} - placeholder="Charge wählen" - /> -
-
- - + + + {({ id }) => ( + honeyBatch.batchNumber} + placeholder="Charge wählen" + /> + )} + + + {({ id }) => ( + product.name} + noneLabel="Kein Produkt" + /> + )} + + + + + + {({ id, describedBy, invalid }) => ( + { + setDate(event.target.value); + clearFieldError("date"); + }} + /> + )} + + setDate(event.target.value)} - /> -
-
- - + {({ id, describedBy, invalid }) => ( + { + setBestBeforeDate(event.target.value); + clearFieldError("bestBeforeDate"); + }} + /> + )} + + + + + + {({ id, describedBy, invalid }) => ( + { + setJarSize(event.target.value); + clearFieldError("jarSize"); + }} + /> + )} + + + {({ id, describedBy, invalid }) => ( + { + setJarCount(event.target.value); + clearFieldError("jarCount"); + }} + /> + )} + + setBestBeforeDate(event.target.value)} - /> -
-
- - setJarSize(event.target.value)} - /> -
-
- - setJarCount(event.target.value)} - /> -
-
- - product.name} - noneLabel="Kein Produkt" - /> -
-
- - setLotNumber(event.target.value)} - /> -
- {error ? ( -

- {error} -

- ) : null} - + error={fieldErrors.lotNumber} + > + {({ id, describedBy, invalid }) => ( + { + setLotNumber(event.target.value); + clearFieldError("lotNumber"); + }} + /> + )} + + + + ); } diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx index 0b7b069..8824165 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx @@ -25,7 +25,7 @@ import { DialogSubmitBar, EditTriggerButton, hasAnyValue, -} from "./edit-dialog-parts"; +} from "@/components/form/edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; interface FieldErrors { diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx index 89987ad..2e1a481 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx @@ -38,7 +38,7 @@ import { DialogSubmitBar, EditTriggerButton, hasAnyValue, -} from "./edit-dialog-parts"; +} from "@/components/form/edit-dialog-parts"; function valuesFromInspection(inspection: Inspection): InspectionFormValues { return { diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx index 8aee524..7e5ccc5 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx @@ -26,7 +26,7 @@ import { DialogSubmitBar, EditTriggerButton, hasAnyValue, -} from "./edit-dialog-parts"; +} from "@/components/form/edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) { diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx index cb77b8a..131f189 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx @@ -39,7 +39,7 @@ import { DialogSubmitBar, EditTriggerButton, hasAnyValue, -} from "./edit-dialog-parts"; +} from "@/components/form/edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; interface CatalogueState { diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx index fdc1801..1b78920 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx @@ -25,7 +25,7 @@ import { DialogSubmitBar, EditTriggerButton, hasAnyValue, -} from "./edit-dialog-parts"; +} from "@/components/form/edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; import { VarroaPeriodField } from "@/components/varroa/varroa-period-field"; import { SampleBeesField } from "@/components/varroa/sample-bees-field"; diff --git a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.spec.tsx index 5b20260..7d7bc5a 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.spec.tsx @@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; import { updateHarvest } from "@/lib/harvests/api"; +import { ApiError } from "@/lib/auth/api-client"; +import { toast } from "sonner"; import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api"; import type { Harvest } from "@/lib/harvests/types"; import { HarvestEditDialog } from "./harvest-edit-dialog"; @@ -85,3 +87,96 @@ describe("HarvestEditDialog – Sperrbezirk", () => { expect(within(dialog).queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument(); }); }); + +describe("HarvestEditDialog – Formular", () => { + beforeEach(() => { + 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(fetchActiveRestrictions).mockResolvedValue([]); + vi.mocked(updateHarvest).mockReset(); + vi.mocked(updateHarvest).mockResolvedValue(harvest); + }); + + async function open() { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ })); + return screen.findByRole("dialog"); + } + const save = () => userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + it("marks what is required and what is optional, in 44 px fields", async () => { + const dialog = await open(); + + expect(within(dialog).getByLabelText(/^Datum/)).toHaveClass("h-11"); + expect(within(dialog).getByLabelText(/^Datum/).closest("div")).toHaveTextContent("Pflicht"); + expect(within(dialog).getByLabelText(/^Honigsorte/).closest("div")).toHaveTextContent("optional"); + expect(within(dialog).getByLabelText(/^Nettogewicht/)).toHaveClass("h-11"); + }); + + it("explains a missing colony at the colony list, focuses it and sends nothing", async () => { + const dialog = await open(); + await userEvent.click(await within(dialog).findByRole("checkbox", { name: "V-01" })); + await save(); + + expect(within(dialog).getByText("Bitte mindestens ein Volk auswählen.")).toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + expect(within(dialog).getByRole("checkbox", { name: "V-01" })).toHaveFocus(); + expect(updateHarvest).not.toHaveBeenCalled(); + }); + + it("an emptied date is explained at the field and nothing is sent", async () => { + const dialog = await open(); + const date = within(dialog).getByLabelText(/^Datum/); + await userEvent.clear(date); + await save(); + + expect(within(dialog).getByText("Bitte ein Datum angeben.")).toBeInTheDocument(); + expect(date).toHaveFocus(); + expect(updateHarvest).not.toHaveBeenCalled(); + }); + + it("sends the same payload as before: emptied optional fields as null, zero stays zero", async () => { + const dialog = await open(); + const net = within(dialog).getByLabelText(/^Nettogewicht/); + await userEvent.type(net, "0"); + await save(); + + await vi.waitFor(() => expect(updateHarvest).toHaveBeenCalledTimes(1)); + expect(vi.mocked(updateHarvest).mock.calls[0]).toEqual([ + "h-1", + { + apiaryId: "apiary-1", + colonyIds: ["colony-1"], + date: expect.stringContaining("2026-08-01"), + honeyType: null, + grossWeight: null, + netWeight: 0, + honeySuperCount: null, + notes: null, + }, + ]); + }); + + it("shows the server's explanation above the save button and keeps the dialog open", async () => { + vi.mocked(updateHarvest).mockRejectedValue(new ApiError(400, "Netto darf nicht über Brutto liegen.")); + const dialog = await open(); + await save(); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Netto darf nicht über Brutto liegen."); + expect(toast.error).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("closes after saving and puts the focus back on the Bearbeiten button", async () => { + await open(); + await save(); + + await vi.waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByRole("button", { name: /Bearbeiten/ })).toHaveFocus(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx index 541208a..dd75e59 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx @@ -1,19 +1,25 @@ "use client"; -import { FormEvent, useState } from "react"; -import { Pencil } from "lucide-react"; +import { FormEvent, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { updateHarvest } from "@/lib/harvests/api"; import { emptyToNull } from "@/lib/forms/empty-to-null"; +import { errorMessage } from "@/lib/forms/error-message"; import { Harvest } from "@/lib/harvests/types"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; -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 { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; import { RecordSelect } from "@/components/form/record-select"; +import { ToggleChip } from "@/components/form/toggle-chip"; +import { + DATE_REQUIRED_MESSAGE, + DialogSubmitBar, + EditTriggerButton, + hasAnyValue, +} from "@/components/form/edit-dialog-parts"; import { Dialog, DialogContent, @@ -24,6 +30,9 @@ import { import { toApiDate } from "@/lib/dates"; import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice"; +const DATE_ID = "harvest-edit-date"; +const NO_COLONY_MESSAGE = "Bitte mindestens ein Volk auswählen."; + export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -39,6 +48,10 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { harvest.honeySuperCount?.toString() ?? "", ); const [notes, setNotes] = useState(harvest.notes ?? ""); + const [dateError, setDateError] = useState(null); + const [colonyError, setColonyError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const colonyListRef = useRef(null); const { data: apiaries } = useQuery({ queryKey: ["apiaries"], @@ -57,6 +70,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { ); function toggleColony(colonyId: string, checked: boolean) { + setColonyError(null); setColonyIds((current) => checked ? [...current, colonyId] @@ -80,14 +94,22 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { void queryClient.invalidateQueries({ queryKey: ["harvests"] }); setOpen(false); }, - onError: (error: Error) => - toast.error(error.message || "Ernte konnte nicht gespeichert werden"), + onError: (error) => + setSubmitError(errorMessage(error, "Ernte konnte nicht gespeichert werden.")), }); function handleSubmit(event: FormEvent) { event.preventDefault(); + setSubmitError(null); + if (!date) { + setDateError(DATE_REQUIRED_MESSAGE); + document.getElementById(DATE_ID)?.focus(); + return; + } if (colonyIds.length === 0) { - toast.error("Bitte mindestens ein Volk auswählen."); + setColonyError(NO_COLONY_MESSAGE); + // Focus the first colony tile on offer. + colonyListRef.current?.querySelector('[role="checkbox"]')?.focus(); return; } mutation.mutate(); @@ -95,118 +117,145 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { return ( - - - Bearbeiten - - } - /> + } /> Ernte bearbeiten -
-
- - apiary.name} - placeholder="Bienenstand wählen" - /> - -
-
- - {selectableColonies.length === 0 ? ( -

- Keine Völker an diesem Bienenstand. -

- ) : null} - {selectableColonies.map((colony) => ( - - ))} -
-
- - setDate(event.target.value)} - /> -
-
- - setHoneyType(event.target.value)} - /> -
-
-
- - setGrossWeight(event.target.value)} - /> -
-
- - setNetWeight(event.target.value)} - /> -
-
- - setHoneySuperCount(event.target.value)} - /> -
-
-
- -