feat: Bearbeiten-Dialoge für Charge, Ernte, Abfüllung und Produkt mit dem Formular-Kit

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:20:09 +02:00
1 parent 1806255211
commit ced65314ba
16 files changed
+1083 -360

No files matched your search

@@ -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(
<QueryClientProvider client={client}>
<BottlingEditDialog bottlingBatch={bottling} />
</QueryClientProvider>,
);
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();
});
});
@@ -1,18 +1,22 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateBottlingBatch } from "@/lib/bottling/api"; import { updateBottlingBatch } from "@/lib/bottling/api";
import { BottlingBatch } from "@/lib/bottling/types"; import { BottlingBatch } from "@/lib/bottling/types";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { 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 { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,6 +26,45 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates"; 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<Record<(typeof FIELD_ORDER)[number], string>>;
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({ export function BottlingEditDialog({
bottlingBatch, bottlingBatch,
}: { }: {
@@ -31,14 +74,7 @@ export function BottlingEditDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton variant="outline" />} />
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Abfüllung bearbeiten</DialogTitle> <DialogTitle>Abfüllung bearbeiten</DialogTitle>
@@ -75,6 +111,7 @@ function BottlingEditForm({
// "" = "Kein Produkt" - choosing it clears the assignment again. // "" = "Kein Produkt" - choosing it clears the assignment again.
const [productId, setProductId] = useState(bottlingBatch.productId ?? ""); const [productId, setProductId] = useState(bottlingBatch.productId ?? "");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const { data: honeyBatches } = useQuery({ const { data: honeyBatches } = useQuery({
queryKey: ["honey-batches"], queryKey: ["honey-batches"],
@@ -127,107 +164,161 @@ function BottlingEditForm({
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
onSaved(); onSaved();
}, },
onError: (mutationError: Error) => { onError: (mutationError) =>
const message = setError(errorMessage(mutationError, "Abfüllung konnte nicht gespeichert werden.")),
mutationError.message || "Abfüllung konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
}); });
function clearFieldError(key: keyof FieldErrors) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(null); 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(); mutation.mutate();
} }
return ( return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2"> <FormSection step={1} title="Welche Charge?">
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label> <Field label="Honigcharge" required>
{({ id }) => (
<RecordSelect <RecordSelect
id="bottling-edit-honey-batch" id={id}
value={honeyBatchId} value={honeyBatchId}
onChange={setHoneyBatchId} onChange={setHoneyBatchId}
items={honeyBatchOptions} items={honeyBatchOptions}
getLabel={(honeyBatch) => honeyBatch.batchNumber} getLabel={(honeyBatch) => honeyBatch.batchNumber}
placeholder="Charge wählen" placeholder="Charge wählen"
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="bottling-edit-date">Datum</Label> <Field label="Produkt">
<Input {({ id }) => (
id="bottling-edit-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-best-before">
Mindesthaltbarkeitsdatum
</Label>
<Input
id="bottling-edit-best-before"
type="date"
required
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-jar-size">Glasgröße (g)</Label>
<Input
id="bottling-edit-jar-size"
type="number"
inputMode="decimal"
min="1"
required
value={jarSize}
onChange={(event) => setJarSize(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-jar-count">Anzahl Gläser</Label>
<Input
id="bottling-edit-jar-count"
type="number"
inputMode="decimal"
min="1"
required
value={jarCount}
onChange={(event) => setJarCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-product">Produkt</Label>
<RecordSelect <RecordSelect
id="bottling-edit-product" id={id}
value={productId} value={productId}
onChange={setProductId} onChange={setProductId}
items={productOptions} items={productOptions}
getLabel={(product) => product.name} getLabel={(product) => product.name}
noneLabel="Kein Produkt" noneLabel="Kein Produkt"
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label> </FormSection>
<FormSection step={2} title="Wann?">
<Field id={FIELD_IDS.date} label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="bottling-edit-lot" id={id}
type="date"
required required
value={lotNumber} className="h-11"
onChange={(event) => setLotNumber(event.target.value)} aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => {
setDate(event.target.value);
clearFieldError("date");
}}
/> />
</div> )}
{error ? ( </Field>
<p role="alert" className="text-sm text-destructive"> <Field
{error} id={FIELD_IDS.bestBeforeDate}
</p> label="Mindesthaltbarkeitsdatum"
) : null} required
<Button type="submit" disabled={mutation.isPending}> error={fieldErrors.bestBeforeDate}
{mutation.isPending ? "Speichert…" : "Speichern"} >
</Button> {({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={bestBeforeDate}
onChange={(event) => {
setBestBeforeDate(event.target.value);
clearFieldError("bestBeforeDate");
}}
/>
)}
</Field>
</FormSection>
<FormSection step={3} title="Gläser">
<Field id={FIELD_IDS.jarSize} label="Glasgröße (g)" required error={fieldErrors.jarSize}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min="1"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={jarSize}
onChange={(event) => {
setJarSize(event.target.value);
clearFieldError("jarSize");
}}
/>
)}
</Field>
<Field id={FIELD_IDS.jarCount} label="Anzahl Gläser" required error={fieldErrors.jarCount}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min="1"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={jarCount}
onChange={(event) => {
setJarCount(event.target.value);
clearFieldError("jarCount");
}}
/>
)}
</Field>
<Field
id={FIELD_IDS.lotNumber}
label="Los-/Chargennummer"
required
error={fieldErrors.lotNumber}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={lotNumber}
onChange={(event) => {
setLotNumber(event.target.value);
clearFieldError("lotNumber");
}}
/>
)}
</Field>
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={error} />
</form> </form>
); );
} }
@@ -25,7 +25,7 @@ import {
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
hasAnyValue, hasAnyValue,
} from "./edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
interface FieldErrors { interface FieldErrors {
@@ -38,7 +38,7 @@ import {
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
hasAnyValue, hasAnyValue,
} from "./edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
function valuesFromInspection(inspection: Inspection): InspectionFormValues { function valuesFromInspection(inspection: Inspection): InspectionFormValues {
return { return {
@@ -26,7 +26,7 @@ import {
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
hasAnyValue, hasAnyValue,
} from "./edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) { export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
@@ -39,7 +39,7 @@ import {
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
hasAnyValue, hasAnyValue,
} from "./edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
interface CatalogueState { interface CatalogueState {
@@ -25,7 +25,7 @@ import {
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
hasAnyValue, hasAnyValue,
} from "./edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field"; import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { SampleBeesField } from "@/components/varroa/sample-bees-field"; import { SampleBeesField } from "@/components/varroa/sample-bees-field";
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { updateHarvest } from "@/lib/harvests/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 { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import type { Harvest } from "@/lib/harvests/types"; import type { Harvest } from "@/lib/harvests/types";
import { HarvestEditDialog } from "./harvest-edit-dialog"; import { HarvestEditDialog } from "./harvest-edit-dialog";
@@ -85,3 +87,96 @@ describe("HarvestEditDialog – Sperrbezirk", () => {
expect(within(dialog).queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument(); 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();
});
});
@@ -1,19 +1,25 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHarvest } from "@/lib/harvests/api"; import { updateHarvest } from "@/lib/harvests/api";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Harvest } from "@/lib/harvests/types"; import { Harvest } from "@/lib/harvests/types";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; 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 { 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -24,6 +30,9 @@ import {
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice"; 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 }) { export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -39,6 +48,10 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
harvest.honeySuperCount?.toString() ?? "", harvest.honeySuperCount?.toString() ?? "",
); );
const [notes, setNotes] = useState(harvest.notes ?? ""); const [notes, setNotes] = useState(harvest.notes ?? "");
const [dateError, setDateError] = useState<string | null>(null);
const [colonyError, setColonyError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const colonyListRef = useRef<HTMLDivElement>(null);
const { data: apiaries } = useQuery({ const { data: apiaries } = useQuery({
queryKey: ["apiaries"], queryKey: ["apiaries"],
@@ -57,6 +70,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
); );
function toggleColony(colonyId: string, checked: boolean) { function toggleColony(colonyId: string, checked: boolean) {
setColonyError(null);
setColonyIds((current) => setColonyIds((current) =>
checked checked
? [...current, colonyId] ? [...current, colonyId]
@@ -80,14 +94,22 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
void queryClient.invalidateQueries({ queryKey: ["harvests"] }); void queryClient.invalidateQueries({ queryKey: ["harvests"] });
setOpen(false); setOpen(false);
}, },
onError: (error: Error) => onError: (error) =>
toast.error(error.message || "Ernte konnte nicht gespeichert werden"), setSubmitError(errorMessage(error, "Ernte konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
if (!date) {
setDateError(DATE_REQUIRED_MESSAGE);
document.getElementById(DATE_ID)?.focus();
return;
}
if (colonyIds.length === 0) { 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<HTMLElement>('[role="checkbox"]')?.focus();
return; return;
} }
mutation.mutate(); mutation.mutate();
@@ -95,118 +117,145 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton variant="outline" />} />
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Ernte bearbeiten</DialogTitle> <DialogTitle>Ernte bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2"> <FormSection step={1} title="Wo und wann?">
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label> <Field label="Bienenstand" required>
{({ id }) => (
<RecordSelect <RecordSelect
id="harvest-edit-apiary" id={id}
value={apiaryId} value={apiaryId}
onChange={setApiaryId} onChange={setApiaryId}
items={apiaries} items={apiaries}
getLabel={(apiary) => apiary.name} getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen" placeholder="Bienenstand wählen"
/> />
)}
</Field>
<HarvestRestrictionWarning apiaryId={apiaryId} /> <HarvestRestrictionWarning apiaryId={apiaryId} />
</div> <Field id={DATE_ID} label="Datum" required error={dateError}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label>Beteiligte Völker</Label> <Input
{selectableColonies.length === 0 ? ( id={id}
<p className="text-sm text-muted-foreground"> type="date"
Keine Völker an diesem Bienenstand. required
</p> className="h-11"
) : null} aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => {
setDate(event.target.value);
setDateError(null);
}}
/>
)}
</Field>
</FormSection>
<FormSection step={2} title="Welche Völker?">
<Field
group
label="Beteiligte Völker"
required
error={colonyError}
hint={
selectableColonies.length === 0
? "Keine Völker an diesem Bienenstand."
: undefined
}
>
{({ labelId, describedBy }) => (
<div
ref={colonyListRef}
role="group"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-2 gap-2"
>
{selectableColonies.map((colony) => ( {selectableColonies.map((colony) => (
<label <ToggleChip
key={colony.id} key={colony.id}
className="flex h-11 items-center gap-3 text-sm" label={colony.code}
htmlFor={`harvest-edit-colony-${colony.id}`}
>
<input
id={`harvest-edit-colony-${colony.id}`}
type="checkbox"
className="size-5"
checked={colonyIds.includes(colony.id)} checked={colonyIds.includes(colony.id)}
onChange={(event) => toggleColony(colony.id, event.target.checked)} onChange={(checked) => toggleColony(colony.id, checked)}
/> />
{colony.code}
</label>
))} ))}
</div> </div>
<div className="flex flex-col gap-2"> )}
<Label htmlFor="harvest-edit-date">Datum</Label> </Field>
</FormSection>
<FormSection step={3} title="Ertrag">
<Field label="Honigsorte">
{({ id }) => (
<Input <Input
id="harvest-edit-date" id={id}
type="date" className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-type">Honigsorte</Label>
<Input
id="harvest-edit-type"
value={honeyType} value={honeyType}
onChange={(event) => setHoneyType(event.target.value)} onChange={(event) => setHoneyType(event.target.value)}
/> />
</div> )}
<div className="flex gap-3"> </Field>
<div className="flex flex-1 flex-col gap-2"> <Field label="Bruttogewicht (kg)">
<Label htmlFor="harvest-edit-gross-weight">Bruttogewicht (kg)</Label> {({ id }) => (
<Input <Input
id="harvest-edit-gross-weight" id={id}
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
className="h-11"
value={grossWeight} value={grossWeight}
onChange={(event) => setGrossWeight(event.target.value)} onChange={(event) => setGrossWeight(event.target.value)}
/> />
</div> )}
<div className="flex flex-1 flex-col gap-2"> </Field>
<Label htmlFor="harvest-edit-weight">Nettogewicht (kg)</Label> <Field label="Nettogewicht (kg)">
{({ id }) => (
<Input <Input
id="harvest-edit-weight" id={id}
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
className="h-11"
value={netWeight} value={netWeight}
onChange={(event) => setNetWeight(event.target.value)} onChange={(event) => setNetWeight(event.target.value)}
/> />
</div> )}
<div className="flex flex-1 flex-col gap-2"> </Field>
<Label htmlFor="harvest-edit-supers">Honigräume</Label> <Field label="Honigräume">
{({ id }) => (
<Input <Input
id="harvest-edit-supers" id={id}
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
className="h-11"
value={honeySuperCount} value={honeySuperCount}
onChange={(event) => setHoneySuperCount(event.target.value)} onChange={(event) => setHoneySuperCount(event.target.value)}
/> />
</div> )}
</div> </Field>
<div className="flex flex-col gap-2"> </FormSection>
<Label htmlFor="harvest-edit-notes">Notizen</Label>
<Textarea <FormSection
id="harvest-edit-notes" step={4}
value={notes} title="Notizen"
onChange={(event) => setNotes(event.target.value)} collapsible
/> defaultOpen={hasAnyValue(notes)}
</div> fieldCount={1}
<Button type="submit" disabled={mutation.isPending}> >
{mutation.isPending ? "Speichert…" : "Speichern"} <Field label="Notizen">
</Button> {({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -0,0 +1,145 @@
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 { updateHoneyBatch } from "@/lib/honey-batches/api";
import type { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
vi.mock("@/lib/honey-batches/api", () => ({ updateHoneyBatch: vi.fn() }));
vi.mock("@/lib/extractions/api", () => ({
fetchExtractions: vi.fn(async () => [{ id: "x1", date: "2026-07-01T00:00:00.000Z", amount: 20 }]),
}));
vi.mock("@/lib/storage/api", () => ({ fetchStorageContainers: vi.fn(async () => []) }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const HONEY_BATCH = {
id: "h1",
batchNumber: "H-1",
extractionId: "x1",
honeyType: "Raps",
amount: 20,
waterContent: 17.5,
storageContainerId: null,
storageContainer: null,
status: "STORED",
createdAt: "2026-01-01T00:00:00.000Z",
measurements: [],
} as HoneyBatch;
async function openDialog(honeyBatch: HoneyBatch = HONEY_BATCH) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<HoneyBatchEditDialog honeyBatch={honeyBatch} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
return screen.findByRole("dialog");
}
const save = () => userEvent.click(screen.getByRole("button", { name: "Speichern" }));
describe("HoneyBatchEditDialog", () => {
beforeEach(() => {
vi.mocked(updateHoneyBatch).mockReset();
vi.mocked(updateHoneyBatch).mockResolvedValue(HONEY_BATCH);
});
it("marks what is required and what is optional, in 44 px fields", async () => {
const dialog = await openDialog();
expect(within(dialog).getByLabelText(/^Chargennummer/)).toHaveClass("h-11");
expect(within(dialog).getAllByText("Pflicht").length).toBe(3);
expect(within(dialog).getByLabelText(/^Menge \(kg\)/).closest("div")).toHaveTextContent("optional");
expect(within(dialog).getByLabelText(/^Wassergehalt \(%\)/)).toHaveClass("h-11");
});
it("an empty batch number is explained at the field, focused, and nothing is sent", async () => {
await openDialog();
const field = screen.getByLabelText(/^Chargennummer/);
await userEvent.clear(field);
await save();
expect(screen.getByText("Bitte eine Chargennummer eingeben.")).toBeInTheDocument();
expect(field).toHaveFocus();
expect(updateHoneyBatch).not.toHaveBeenCalled();
});
it.each(["-1", "100.5"])("refuses a water content of %s at the field", async (bad) => {
await openDialog();
const field = screen.getByLabelText(/^Wassergehalt/);
await userEvent.clear(field);
await userEvent.type(field, bad);
await save();
expect(screen.getByText("Bitte einen Wert von 0 bis 100 angeben.")).toBeInTheDocument();
expect(field).toHaveFocus();
expect(updateHoneyBatch).not.toHaveBeenCalled();
});
it("refuses a negative amount at the field", async () => {
await openDialog();
const field = screen.getByLabelText(/^Menge \(kg\)/);
await userEvent.clear(field);
await userEvent.type(field, "-3");
await save();
expect(screen.getByText("Bitte 0 oder mehr angeben.")).toBeInTheDocument();
expect(updateHoneyBatch).not.toHaveBeenCalled();
});
it("sends the unchanged batch with the same payload as before", async () => {
await openDialog();
await save();
await waitFor(() => expect(updateHoneyBatch).toHaveBeenCalledTimes(1));
expect(vi.mocked(updateHoneyBatch).mock.calls[0]).toEqual([
"h1",
{
batchNumber: "H-1",
extractionId: "x1",
status: "STORED",
storageContainerId: null,
honeyType: "Raps",
amount: 20,
waterContent: 17.5,
},
]);
});
it("sends emptied optional fields as null, a zero stays zero", async () => {
await openDialog();
await userEvent.clear(screen.getByLabelText(/^Sorte/));
await userEvent.clear(screen.getByLabelText(/^Wassergehalt/));
const amount = screen.getByLabelText(/^Menge \(kg\)/);
await userEvent.clear(amount);
await userEvent.type(amount, "0");
await save();
await waitFor(() => expect(updateHoneyBatch).toHaveBeenCalled());
expect(vi.mocked(updateHoneyBatch).mock.calls[0][1]).toMatchObject({
honeyType: null,
waterContent: null,
amount: 0,
});
});
it("shows the server's explanation above the save button and keeps the dialog open", async () => {
vi.mocked(updateHoneyBatch).mockRejectedValue(new ApiError(409, "Chargennummer H-1 gibt es schon."));
const dialog = await openDialog();
await save();
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Chargennummer H-1 gibt es schon.");
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
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();
});
});
@@ -3,9 +3,9 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyBatch } from "@/lib/honey-batches/api"; import { updateHoneyBatch } from "@/lib/honey-batches/api";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types"; import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels"; import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api"; import { fetchExtractions } from "@/lib/extractions/api";
@@ -13,19 +13,57 @@ import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
import { DialogSubmitBar } from "@/components/form/edit-dialog-parts";
import { storageContainerOptionLabel } from "@/lib/storage/labels"; import { storageContainerOptionLabel } from "@/lib/storage/labels";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
const BATCH_NUMBER_ID = "batch-edit-number";
const AMOUNT_ID = "batch-edit-amount";
const WATER_ID = "batch-edit-water";
const BATCH_NUMBER_REQUIRED = "Bitte eine Chargennummer eingeben.";
const AMOUNT_PROBLEM = "Bitte 0 oder mehr angeben.";
const WATER_PROBLEM = "Bitte einen Wert von 0 bis 100 angeben.";
const MAX_WATER_PERCENT = 100;
interface FieldErrors {
batchNumber?: string;
amount?: string;
waterContent?: string;
}
/** Empty is fine; a filled number must lie inside the bounds the number fields used to enforce natively. */
function outOfRange(value: string, min: number, max = Infinity): boolean {
if (value.trim() === "") return false;
const number = Number(value);
return !Number.isFinite(number) || number < min || number > max;
}
function validate(batchNumber: string, amount: string, waterContent: string): FieldErrors {
return {
batchNumber: batchNumber.trim() === "" ? BATCH_NUMBER_REQUIRED : undefined,
amount: outOfRange(amount, 0) ? AMOUNT_PROBLEM : undefined,
waterContent: outOfRange(waterContent, 0, MAX_WATER_PERCENT) ? WATER_PROBLEM : undefined,
};
}
function firstInvalidId(errors: FieldErrors): string | null {
if (errors.batchNumber) return BATCH_NUMBER_ID;
if (errors.amount) return AMOUNT_ID;
if (errors.waterContent) return WATER_ID;
return null;
}
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) { export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -38,6 +76,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? ""); const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? ""); const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? ""); const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const { isOffline, hintId, describedBy } = useOnlineOnly(); const { isOffline, hintId, describedBy } = useOnlineOnly();
@@ -66,11 +106,24 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Honigcharge konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Honigcharge konnte nicht gespeichert werden.")),
}); });
function clearError(key: keyof FieldErrors) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
const errors = validate(batchNumber, amount, waterContent);
setFieldErrors(errors);
const firstInvalid = firstInvalidId(errors);
if (firstInvalid) {
document.getElementById(firstInvalid)?.focus();
return;
}
mutation.mutate(); mutation.mutate();
} }
@@ -89,20 +142,28 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
<DialogHeader> <DialogHeader>
<DialogTitle>Honigcharge bearbeiten</DialogTitle> <DialogTitle>Honigcharge bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2"> <FormSection step={1} title="Welche Charge?">
<Label htmlFor="batch-edit-number">Chargennummer</Label> <Field id={BATCH_NUMBER_ID} label="Chargennummer" required error={fieldErrors.batchNumber}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input <Input
id="batch-edit-number" id={id}
required required
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={batchNumber} value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)} onChange={(event) => {
setBatchNumber(event.target.value);
clearError("batchNumber");
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="batch-edit-extraction">Schleuderung</Label> <Field label="Schleuderung" required>
{({ id }) => (
<RecordSelect <RecordSelect
id="batch-edit-extraction" id={id}
value={extractionId} value={extractionId}
onChange={setExtractionId} onChange={setExtractionId}
items={extractions} items={extractions}
@@ -113,68 +174,91 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
} }
placeholder="Schleuderung wählen" placeholder="Schleuderung wählen"
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="batch-edit-container">Lagerbehälter</Label> <Field label="Status" required>
{({ id }) => (
<EnumSelect
id={id}
value={status}
onChange={setStatus}
labels={HONEY_BATCH_STATUS_LABELS}
/>
)}
</Field>
</FormSection>
<FormSection step={2} title="Menge & Qualität">
<Field label="Sorte">
{({ id }) => (
<Input
id={id}
className="h-11"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
)}
</Field>
<Field id={AMOUNT_ID} label="Menge (kg)" error={fieldErrors.amount}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="decimal"
min={0}
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={amount}
onChange={(event) => {
setAmount(event.target.value);
clearError("amount");
}}
/>
)}
</Field>
<Field id={WATER_ID} label="Wassergehalt (%)" error={fieldErrors.waterContent}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="decimal"
step="0.1"
min={0}
max={MAX_WATER_PERCENT}
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={waterContent}
onChange={(event) => {
setWaterContent(event.target.value);
clearError("waterContent");
}}
/>
)}
</Field>
</FormSection>
<FormSection step={3} title="Lagerung">
<Field
label="Lagerbehälter"
hint="Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie festgehalten."
>
{({ id, describedBy: fieldDescribedBy }) => (
<RecordSelect <RecordSelect
id="batch-edit-container" id={id}
value={storageContainerId} value={storageContainerId}
onChange={setStorageContainerId} onChange={setStorageContainerId}
items={containers} items={containers}
getLabel={storageContainerOptionLabel} getLabel={storageContainerOptionLabel}
placeholder="Behälter wählen" placeholder="Behälter wählen"
describedBy={fieldDescribedBy}
/> />
<p className="text-xs text-muted-foreground"> )}
Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie </Field>
festgehalten. </FormSection>
</p>
</div> <DialogSubmitBar isPending={mutation.isPending} error={submitError} />
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-status">Status</Label>
<EnumSelect
id="batch-edit-status"
value={status}
onChange={setStatus}
labels={HONEY_BATCH_STATUS_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
<Input
id="batch-edit-honey-type"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-amount">Menge (kg)</Label>
<Input
id="batch-edit-amount"
type="number"
inputMode="decimal"
min={0}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-water">Wassergehalt (%)</Label>
<Input
id="batch-edit-water"
type="number"
inputMode="decimal"
step="0.1"
min={0}
max={100}
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { EditProductDialog } from "./edit-product-dialog"; import { EditProductDialog } from "./edit-product-dialog";
import { updateProduct } from "@/lib/products/api"; import { updateProduct } from "@/lib/products/api";
import { ApiError } from "@/lib/auth/api-client";
import { toast } from "sonner";
import type { Product } from "@/lib/products/types"; import type { Product } from "@/lib/products/types";
vi.mock("@/lib/products/api", () => ({ updateProduct: vi.fn() })); vi.mock("@/lib/products/api", () => ({ updateProduct: vi.fn() }));
@@ -85,7 +87,7 @@ describe("EditProductDialog", () => {
it("renames a product with a legacy decimal packagingSize without converting it", async () => { it("renames a product with a legacy decimal packagingSize without converting it", async () => {
await openDialog({ ...PRODUCT, packagingSize: 250.5 }); await openDialog({ ...PRODUCT, packagingSize: 250.5 });
const name = screen.getByLabelText("Name"); const name = screen.getByLabelText(/^Name/);
await userEvent.clear(name); await userEvent.clear(name);
await userEvent.type(name, "Frühtracht neu"); await userEvent.type(name, "Frühtracht neu");
await userEvent.click(screen.getByRole("button", { name: "Speichern" })); await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
@@ -99,15 +101,15 @@ describe("EditProductDialog", () => {
it("starts from the stored product each time it opens", async () => { it("starts from the stored product each time it opens", async () => {
await openDialog(); await openDialog();
const name = screen.getByLabelText("Name"); const name = screen.getByLabelText(/^Name/);
await userEvent.clear(name); await userEvent.clear(name);
await userEvent.type(name, "Etwas anderes"); await userEvent.type(name, "Etwas anderes");
await userEvent.keyboard("{Escape}"); await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByLabelText("Name")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByLabelText(/^Name/)).not.toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ })); await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht"); expect(screen.getByLabelText(/^Name/)).toHaveValue("Frühtracht");
}); });
it("the product form shows, saves and clears the label name", async () => { it("the product form shows, saves and clears the label name", async () => {
@@ -121,4 +123,75 @@ describe("EditProductDialog", () => {
await waitFor(() => expect(updateProduct).toHaveBeenCalled()); await waitFor(() => expect(updateProduct).toHaveBeenCalled());
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ legalName: null }); expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ legalName: null });
}); });
it("marks what is required and what is optional, in 44 px fields", async () => {
await openDialog();
expect(screen.getByLabelText(/^Name/).closest("div")).toHaveTextContent("Pflicht");
expect(screen.getByLabelText(/^Name/)).toHaveClass("h-11");
expect(screen.getByLabelText(/^SKU/).closest("div")).toHaveTextContent("optional");
expect(screen.getByLabelText(/Gewicht pro Glas \(g\)/)).toHaveClass("h-11");
});
it("an empty name is explained at the field, focused, and nothing is sent", async () => {
await openDialog();
const name = screen.getByLabelText(/^Name/);
await userEvent.clear(name);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
expect(name).toHaveFocus();
expect(updateProduct).not.toHaveBeenCalled();
});
it("shows the shelf-life problem at its field and focuses it", async () => {
await openDialog();
const field = screen.getByLabelText(/Haltbarkeit \(Monate\)/);
await userEvent.clear(field);
await userEvent.type(field, "61");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
expect(field).toHaveFocus();
expect(updateProduct).not.toHaveBeenCalled();
});
it("sends the unchanged product with the same payload as before", async () => {
await openDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateProduct).toHaveBeenCalledTimes(1));
expect(vi.mocked(updateProduct).mock.calls[0]).toEqual([
"p-1",
{
name: "Frühtracht",
description: null,
sku: null,
packagingSize: 500,
shelfLifeMonths: 24,
legalName: "Blütenhonig",
weight: null,
salesUnit: null,
status: "ACTIVE",
shopCategory: "HONIG",
},
]);
});
it("shows the server's explanation above the save button instead of a vanishing toast", async () => {
vi.mocked(updateProduct).mockRejectedValue(new ApiError(409, "Die SKU gibt es schon."));
await openDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByText("Die SKU gibt es schon.")).toBeInTheDocument();
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 openDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Bearbeiten/ })).toHaveFocus();
});
}); });
@@ -1,11 +1,10 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api"; import { updateProduct } from "@/lib/products/api";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation"; import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types"; import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import { import {
@@ -13,20 +12,29 @@ import {
PRODUCT_STATUS_LABELS, PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS, SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels"; } from "@/lib/products/labels";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import {
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "@/components/form/edit-dialog-parts";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
const NAME_ID = "product-edit-name";
const PACKAGING_ID = "product-edit-packaging-size";
const SHELF_LIFE_ID = "product-edit-shelf-life";
const NAME_REQUIRED = "Bitte einen Namen eingeben.";
export function EditProductDialog({ product }: { product: Product }) { export function EditProductDialog({ product }: { product: Product }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -39,6 +47,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [nameError, setNameError] = useState<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
const [packagingError, setPackagingError] = useState<string | null>(null); const [packagingError, setPackagingError] = useState<string | null>(null);
const [shelfLifeError, setShelfLifeError] = useState<string | null>(null); const [shelfLifeError, setShelfLifeError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [weight, setWeight] = useState(product.weight?.toString() ?? ""); const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? ""); const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const [status, setStatus] = useState<ProductStatus>(product.status); const [status, setStatus] = useState<ProductStatus>(product.status);
@@ -58,6 +67,7 @@ export function EditProductDialog({ product }: { product: Product }) {
setNameError(null); setNameError(null);
setPackagingError(null); setPackagingError(null);
setShelfLifeError(null); setShelfLifeError(null);
setSubmitError(null);
} }
const mutation = useMutation({ const mutation = useMutation({
@@ -78,25 +88,27 @@ export function EditProductDialog({ product }: { product: Product }) {
void queryClient.invalidateQueries({ queryKey: ["products"] }); void queryClient.invalidateQueries({ queryKey: ["products"] });
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Produkt konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Produkt konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
// An unchanged legacy value (e.g. 250.5) must not block a rename; it is never converted. // An unchanged legacy value (e.g. 250.5) must not block a rename; it is never converted.
const packagingChanged = packagingSize.trim() !== (product.packagingSize?.toString() ?? ""); const packagingChanged = packagingSize.trim() !== (product.packagingSize?.toString() ?? "");
const packaging = packagingChanged ? packagingSizeProblem(packagingSize) : null; const packaging = packagingChanged ? packagingSizeProblem(packagingSize) : null;
const shelfLife = shelfLifeProblem(shelfLifeMonths); const shelfLife = shelfLifeProblem(shelfLifeMonths);
const missingName = name.trim() === "" ? "Bitte einen Namen eingeben." : null; const missingName = name.trim() === "" ? NAME_REQUIRED : null;
setNameError(missingName); setNameError(missingName);
setPackagingError(packaging); setPackagingError(packaging);
setShelfLifeError(shelfLife); setShelfLifeError(shelfLife);
const firstInvalid = missingName const firstInvalid = missingName
? "product-edit-name" ? NAME_ID
: packaging : packaging
? "product-edit-packaging-size" ? PACKAGING_ID
: shelfLife : shelfLife
? "product-edit-shelf-life" ? SHELF_LIFE_ID
: null; : null;
if (firstInvalid) { if (firstInvalid) {
document.getElementById(firstInvalid)?.focus(); document.getElementById(firstInvalid)?.focus();
@@ -113,166 +125,172 @@ export function EditProductDialog({ product }: { product: Product }) {
setOpen(next); setOpen(next);
}} }}
> >
<DialogTrigger <DialogTrigger render={<EditTriggerButton size="sm" />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Produkt bearbeiten</DialogTitle> <DialogTitle>Produkt bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2"> <FormSection step={1} title="Welches Produkt?">
<Label htmlFor="product-edit-name">Name</Label> <Field id={NAME_ID} label="Name" required error={nameError}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="product-edit-name" id={id}
required required
aria-invalid={nameError ? true : undefined} className="h-11"
aria-describedby={nameError ? "product-edit-name-error" : undefined} aria-invalid={invalid}
aria-describedby={describedBy}
value={name} value={name}
onChange={(event) => { onChange={(event) => {
setName(event.target.value); setName(event.target.value);
setNameError(null); setNameError(null);
}} }}
/> />
{nameError ? ( )}
<p id="product-edit-name-error" role="alert" className="text-sm font-medium text-destructive"> </Field>
{nameError} <Field label="SKU">
</p> {({ id }) => (
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-description">Beschreibung</Label>
<Textarea
id="product-edit-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sku">SKU</Label>
<Input <Input
id="product-edit-sku" id={id}
className="h-11"
value={sku} value={sku}
onChange={(event) => setSku(event.target.value)} onChange={(event) => setSku(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="product-edit-packaging-size">Gewicht pro Glas (g)</Label> <Field label="Status" required>
{({ id }) => (
<EnumSelect
id={id}
value={status}
onChange={setStatus}
labels={PRODUCT_STATUS_LABELS}
/>
)}
</Field>
<Field
label="Kategorie im Webshop"
required
hint={`Art: ${PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.`}
>
{({ id, describedBy }) => (
<EnumSelect
id={id}
value={shopCategory}
onChange={setShopCategory}
labels={SHOP_CATEGORY_LABELS}
describedBy={describedBy}
/>
)}
</Field>
</FormSection>
<FormSection step={2} title="Glas & Haltbarkeit">
<Field id={PACKAGING_ID} label="Gewicht pro Glas (g)" error={packagingError}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="product-edit-packaging-size" id={id}
type="number" type="number"
inputMode="numeric" inputMode="numeric"
step={1} step={1}
min={1} min={1}
max={10000} max={10000}
aria-invalid={packagingError ? true : undefined} className="h-11"
aria-describedby={packagingError ? "product-edit-packaging-size-error" : undefined} aria-invalid={invalid}
aria-describedby={describedBy}
value={packagingSize} value={packagingSize}
onChange={(event) => { onChange={(event) => {
setPackagingSize(event.target.value); setPackagingSize(event.target.value);
setPackagingError(null); setPackagingError(null);
}} }}
/> />
{packagingError ? ( )}
<p id="product-edit-packaging-size-error" role="alert" className="text-sm font-medium text-destructive"> </Field>
{packagingError} <Field id={SHELF_LIFE_ID} label="Haltbarkeit (Monate)" error={shelfLifeError}>
</p> {({ id, describedBy, invalid }) => (
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shelf-life">Haltbarkeit (Monate)</Label>
<Input <Input
id="product-edit-shelf-life" id={id}
type="number" type="number"
inputMode="numeric" inputMode="numeric"
step={1} step={1}
min={1} min={1}
max={60} max={60}
aria-invalid={shelfLifeError ? true : undefined} className="h-11"
aria-describedby={shelfLifeError ? "product-edit-shelf-life-error" : undefined} aria-invalid={invalid}
aria-describedby={describedBy}
value={shelfLifeMonths} value={shelfLifeMonths}
onChange={(event) => { onChange={(event) => {
setShelfLifeMonths(event.target.value); setShelfLifeMonths(event.target.value);
setShelfLifeError(null); setShelfLifeError(null);
}} }}
/> />
{shelfLifeError ? ( )}
<p id="product-edit-shelf-life-error" role="alert" className="text-sm font-medium text-destructive"> </Field>
{shelfLifeError} <Field
</p> label="Artikelgewicht gesamt (g)"
) : null} hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas."
</div> >
<div className="flex flex-col gap-2"> {({ id, describedBy }) => (
<Label htmlFor="product-edit-legal-name">
Bezeichnung auf dem Etikett (z. B. Blütenhonig)
</Label>
<Input <Input
id="product-edit-legal-name" id={id}
maxLength={200}
aria-describedby="product-edit-legal-name-hint"
value={legalName}
onChange={(event) => setLegalName(event.target.value)}
/>
<p id="product-edit-legal-name-hint" className="text-xs text-muted-foreground">
Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
<Input
id="product-edit-weight"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="0.01" step="0.01"
min={0} min={0}
aria-describedby="product-edit-weight-hint" className="h-11"
aria-describedby={describedBy}
value={weight} value={weight}
onChange={(event) => setWeight(event.target.value)} onChange={(event) => setWeight(event.target.value)}
/> />
<p id="product-edit-weight-hint" className="text-xs text-muted-foreground"> )}
Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas. </Field>
</p> <Field label="Verkaufseinheit">
</div> {({ id }) => (
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-status">Status</Label>
<EnumSelect
id="product-edit-status"
value={status}
onChange={setStatus}
labels={PRODUCT_STATUS_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shop-category">Kategorie im Webshop</Label>
<EnumSelect
id="product-edit-shop-category"
value={shopCategory}
onChange={setShopCategory}
labels={SHOP_CATEGORY_LABELS}
/>
<p className="text-xs text-muted-foreground">
Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
<Input <Input
id="product-edit-sales-unit" id={id}
className="h-11"
placeholder="z. B. Glas, Flasche" placeholder="z. B. Glas, Flasche"
value={salesUnit} value={salesUnit}
onChange={(event) => setSalesUnit(event.target.value)} onChange={(event) => setSalesUnit(event.target.value)}
/> />
</div> )}
<DialogFooter> </Field>
<Button type="submit" disabled={mutation.isPending}> </FormSection>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button> <FormSection
</DialogFooter> step={3}
title="Etikett & Beschreibung"
collapsible
defaultOpen={hasAnyValue(legalName, description)}
fieldCount={2}
>
<Field
label="Bezeichnung auf dem Etikett (z. B. Blütenhonig)"
hint="Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort."
>
{({ id, describedBy }) => (
<Input
id={id}
maxLength={200}
className="h-11"
aria-describedby={describedBy}
value={legalName}
onChange={(event) => setLegalName(event.target.value)}
/>
)}
</Field>
<Field label="Beschreibung">
{({ id }) => (
<Textarea
id={id}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
)}
</Field>
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -34,7 +34,7 @@ import {
DATE_REQUIRED_MESSAGE, DATE_REQUIRED_MESSAGE,
DialogSubmitBar, DialogSubmitBar,
EditTriggerButton, EditTriggerButton,
} from "../../colonies/[id]/edit-dialog-parts"; } from "@/components/form/edit-dialog-parts";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
type CriterionKey = keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">; type CriterionKey = keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">;
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { EditTriggerButton } from "./edit-dialog-parts"; import { EditTriggerButton } from "@/components/form/edit-dialog-parts";
let sessionMode: "online" | "offline" = "online"; let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode })); vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode }));