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";
|
"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
-1
@@ -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 }));
|
||||||
Reference in new issue
Block a user