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:
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";
|
||||
|
||||
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<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({
|
||||
bottlingBatch,
|
||||
}: {
|
||||
@@ -31,14 +74,7 @@ export function BottlingEditDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton variant="outline" />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
|
||||
@@ -75,6 +111,7 @@ function BottlingEditForm({
|
||||
// "" = "Kein Produkt" - choosing it clears the assignment again.
|
||||
const [productId, setProductId] = useState(bottlingBatch.productId ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welche Charge?">
|
||||
<Field label="Honigcharge" required>
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id="bottling-edit-honey-batch"
|
||||
id={id}
|
||||
value={honeyBatchId}
|
||||
onChange={setHoneyBatchId}
|
||||
items={honeyBatchOptions}
|
||||
getLabel={(honeyBatch) => honeyBatch.batchNumber}
|
||||
placeholder="Charge wählen"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-date">Datum</Label>
|
||||
<Input
|
||||
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>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Produkt">
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id="bottling-edit-product"
|
||||
id={id}
|
||||
value={productId}
|
||||
onChange={setProductId}
|
||||
items={productOptions}
|
||||
getLabel={(product) => product.name}
|
||||
noneLabel="Kein Produkt"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Wann?">
|
||||
<Field id={FIELD_IDS.date} label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="bottling-edit-lot"
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
clearFieldError("date");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
id={FIELD_IDS.bestBeforeDate}
|
||||
label="Mindesthaltbarkeitsdatum"
|
||||
required
|
||||
error={fieldErrors.bestBeforeDate}
|
||||
>
|
||||
{({ 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
DialogSubmitBar,
|
||||
EditTriggerButton,
|
||||
hasAnyValue,
|
||||
} from "./edit-dialog-parts";
|
||||
} from "@/components/form/edit-dialog-parts";
|
||||
|
||||
function valuesFromInspection(inspection: Inspection): InspectionFormValues {
|
||||
return {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<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({
|
||||
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<HTMLFormElement>) {
|
||||
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<HTMLElement>('[role="checkbox"]')?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
@@ -95,118 +117,145 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton variant="outline" />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ernte bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Wo und wann?">
|
||||
<Field label="Bienenstand" required>
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id="harvest-edit-apiary"
|
||||
id={id}
|
||||
value={apiaryId}
|
||||
onChange={setApiaryId}
|
||||
items={apiaries}
|
||||
getLabel={(apiary) => apiary.name}
|
||||
placeholder="Bienenstand wählen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<HarvestRestrictionWarning apiaryId={apiaryId} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Beteiligte Völker</Label>
|
||||
{selectableColonies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keine Völker an diesem Bienenstand.
|
||||
</p>
|
||||
) : null}
|
||||
<Field id={DATE_ID} label="Datum" required error={dateError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
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) => (
|
||||
<label
|
||||
<ToggleChip
|
||||
key={colony.id}
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor={`harvest-edit-colony-${colony.id}`}
|
||||
>
|
||||
<input
|
||||
id={`harvest-edit-colony-${colony.id}`}
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
label={colony.code}
|
||||
checked={colonyIds.includes(colony.id)}
|
||||
onChange={(event) => toggleColony(colony.id, event.target.checked)}
|
||||
onChange={(checked) => toggleColony(colony.id, checked)}
|
||||
/>
|
||||
{colony.code}
|
||||
</label>
|
||||
))}
|
||||
</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
|
||||
id="harvest-edit-date"
|
||||
type="date"
|
||||
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"
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={honeyType}
|
||||
onChange={(event) => setHoneyType(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-gross-weight">Bruttogewicht (kg)</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Bruttogewicht (kg)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="harvest-edit-gross-weight"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={grossWeight}
|
||||
onChange={(event) => setGrossWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-weight">Nettogewicht (kg)</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Nettogewicht (kg)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="harvest-edit-weight"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={netWeight}
|
||||
onChange={(event) => setNetWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-supers">Honigräume</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Honigräume">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="harvest-edit-supers"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={honeySuperCount}
|
||||
onChange={(event) => setHoneySuperCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="harvest-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={4}
|
||||
title="Notizen"
|
||||
collapsible
|
||||
defaultOpen={hasAnyValue(notes)}
|
||||
fieldCount={1}
|
||||
>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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 { Pencil } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateHoneyBatch } from "@/lib/honey-batches/api";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
|
||||
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { DialogSubmitBar } from "@/components/form/edit-dialog-parts";
|
||||
import { storageContainerOptionLabel } from "@/lib/storage/labels";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -38,6 +76,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
|
||||
const [amount, setAmount] = useState(honeyBatch.amount?.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();
|
||||
|
||||
@@ -66,11 +106,24 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
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>) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -89,20 +142,28 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
<DialogHeader>
|
||||
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-number">Chargennummer</Label>
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welche Charge?">
|
||||
<Field id={BATCH_NUMBER_ID} label="Chargennummer" required error={fieldErrors.batchNumber}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id="batch-edit-number"
|
||||
id={id}
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={fieldDescribedBy}
|
||||
aria-invalid={invalid}
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setBatchNumber(event.target.value);
|
||||
clearError("batchNumber");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Schleuderung" required>
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id="batch-edit-extraction"
|
||||
id={id}
|
||||
value={extractionId}
|
||||
onChange={setExtractionId}
|
||||
items={extractions}
|
||||
@@ -113,68 +174,91 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
}
|
||||
placeholder="Schleuderung wählen"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-container">Lagerbehälter</Label>
|
||||
)}
|
||||
</Field>
|
||||
<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
|
||||
id="batch-edit-container"
|
||||
id={id}
|
||||
value={storageContainerId}
|
||||
onChange={setStorageContainerId}
|
||||
items={containers}
|
||||
getLabel={storageContainerOptionLabel}
|
||||
placeholder="Behälter wählen"
|
||||
describedBy={fieldDescribedBy}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie
|
||||
festgehalten.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { EditProductDialog } from "./edit-product-dialog";
|
||||
import { updateProduct } from "@/lib/products/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { toast } from "sonner";
|
||||
import type { Product } from "@/lib/products/types";
|
||||
|
||||
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 () => {
|
||||
await openDialog({ ...PRODUCT, packagingSize: 250.5 });
|
||||
const name = screen.getByLabelText("Name");
|
||||
const name = screen.getByLabelText(/^Name/);
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Frühtracht neu");
|
||||
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 () => {
|
||||
await openDialog();
|
||||
const name = screen.getByLabelText("Name");
|
||||
const name = screen.getByLabelText(/^Name/);
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Etwas anderes");
|
||||
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/ }));
|
||||
|
||||
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 () => {
|
||||
@@ -121,4 +123,75 @@ describe("EditProductDialog", () => {
|
||||
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
|
||||
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";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateProduct } from "@/lib/products/api";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
|
||||
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
|
||||
import {
|
||||
@@ -13,20 +12,29 @@ import {
|
||||
PRODUCT_STATUS_LABELS,
|
||||
SHOP_CATEGORY_LABELS,
|
||||
} from "@/lib/products/labels";
|
||||
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 { 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -39,6 +47,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [packagingError, setPackagingError] = 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 [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
|
||||
const [status, setStatus] = useState<ProductStatus>(product.status);
|
||||
@@ -58,6 +67,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
setNameError(null);
|
||||
setPackagingError(null);
|
||||
setShelfLifeError(null);
|
||||
setSubmitError(null);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -78,25 +88,27 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
setSubmitError(null);
|
||||
// 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 packaging = packagingChanged ? packagingSizeProblem(packagingSize) : null;
|
||||
const shelfLife = shelfLifeProblem(shelfLifeMonths);
|
||||
const missingName = name.trim() === "" ? "Bitte einen Namen eingeben." : null;
|
||||
const missingName = name.trim() === "" ? NAME_REQUIRED : null;
|
||||
setNameError(missingName);
|
||||
setPackagingError(packaging);
|
||||
setShelfLifeError(shelfLife);
|
||||
const firstInvalid = missingName
|
||||
? "product-edit-name"
|
||||
? NAME_ID
|
||||
: packaging
|
||||
? "product-edit-packaging-size"
|
||||
? PACKAGING_ID
|
||||
: shelfLife
|
||||
? "product-edit-shelf-life"
|
||||
? SHELF_LIFE_ID
|
||||
: null;
|
||||
if (firstInvalid) {
|
||||
document.getElementById(firstInvalid)?.focus();
|
||||
@@ -113,166 +125,172 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton size="sm" />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Produkt bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-name">Name</Label>
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Produkt?">
|
||||
<Field id={NAME_ID} label="Name" required error={nameError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="product-edit-name"
|
||||
id={id}
|
||||
required
|
||||
aria-invalid={nameError ? true : undefined}
|
||||
aria-describedby={nameError ? "product-edit-name-error" : undefined}
|
||||
className="h-11"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={describedBy}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
/>
|
||||
{nameError ? (
|
||||
<p id="product-edit-name-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{nameError}
|
||||
</p>
|
||||
) : 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>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="SKU">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="product-edit-sku"
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={sku}
|
||||
onChange={(event) => setSku(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-packaging-size">Gewicht pro Glas (g)</Label>
|
||||
)}
|
||||
</Field>
|
||||
<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
|
||||
id="product-edit-packaging-size"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={10000}
|
||||
aria-invalid={packagingError ? true : undefined}
|
||||
aria-describedby={packagingError ? "product-edit-packaging-size-error" : undefined}
|
||||
className="h-11"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={describedBy}
|
||||
value={packagingSize}
|
||||
onChange={(event) => {
|
||||
setPackagingSize(event.target.value);
|
||||
setPackagingError(null);
|
||||
}}
|
||||
/>
|
||||
{packagingError ? (
|
||||
<p id="product-edit-packaging-size-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{packagingError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-shelf-life">Haltbarkeit (Monate)</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field id={SHELF_LIFE_ID} label="Haltbarkeit (Monate)" error={shelfLifeError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="product-edit-shelf-life"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={60}
|
||||
aria-invalid={shelfLifeError ? true : undefined}
|
||||
aria-describedby={shelfLifeError ? "product-edit-shelf-life-error" : undefined}
|
||||
className="h-11"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={describedBy}
|
||||
value={shelfLifeMonths}
|
||||
onChange={(event) => {
|
||||
setShelfLifeMonths(event.target.value);
|
||||
setShelfLifeError(null);
|
||||
}}
|
||||
/>
|
||||
{shelfLifeError ? (
|
||||
<p id="product-edit-shelf-life-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{shelfLifeError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-legal-name">
|
||||
Bezeichnung auf dem Etikett (z. B. Blütenhonig)
|
||||
</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Artikelgewicht gesamt (g)"
|
||||
hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="product-edit-legal-name"
|
||||
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"
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
aria-describedby="product-edit-weight-hint"
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
value={weight}
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Verkaufseinheit">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="product-edit-sales-unit"
|
||||
id={id}
|
||||
className="h-11"
|
||||
placeholder="z. B. Glas, Flasche"
|
||||
value={salesUnit}
|
||||
onChange={(event) => setSalesUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
DATE_REQUIRED_MESSAGE,
|
||||
DialogSubmitBar,
|
||||
EditTriggerButton,
|
||||
} from "../../colonies/[id]/edit-dialog-parts";
|
||||
} from "@/components/form/edit-dialog-parts";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
|
||||
type CriterionKey = keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
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";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode }));
|
||||
Reference in new issue
Block a user