feat(ui): capture forms for treatments, feedings, colonies, apiaries, products, equipment
Same structure as the inspection form: required fields marked, errors at the field, numbered sections with rarely used fields folded, drafts kept on the device, save button in thumb reach, plain hints for jargon (Wirkstoff, Wartezeit, Code, SKU, units). The Bestandsbuch fields of a treatment stay a visible section of their own. Apiaries can take the coordinates from the phone's location. Offline queue and idempotency are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
832997f885
commit
62e78498bf
18 files changed
+2201
-560
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import type { ApiaryInput } from "@/lib/apiaries/types";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ApiaryFormValues {
|
||||
name: string;
|
||||
location: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export type ApiaryFormErrors = Partial<Record<keyof ApiaryFormValues, string>>;
|
||||
|
||||
export function initialApiaryValues(): ApiaryFormValues {
|
||||
return {
|
||||
name: "",
|
||||
location: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Required fields in form order, so the first error is the first field. */
|
||||
export function validateApiary(values: ApiaryFormValues): ApiaryFormErrors {
|
||||
const errors: ApiaryFormErrors = {};
|
||||
if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Six decimals are about 10 cm - more precise than any phone GPS. */
|
||||
export function formatCoordinate(value: number): string {
|
||||
return value.toFixed(6);
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
const numberOrUndefined = (value: string) =>
|
||||
value.trim() ? Number(value) : undefined;
|
||||
|
||||
export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
location: textOrUndefined(values.location),
|
||||
latitude: numberOrUndefined(values.latitude),
|
||||
longitude: numberOrUndefined(values.longitude),
|
||||
address: textOrUndefined(values.address),
|
||||
notes: textOrUndefined(values.notes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewApiaryPage from "./page";
|
||||
import { createApiary } from "@/lib/apiaries/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ createApiary: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewApiaryPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function mockGeolocation(
|
||||
getCurrentPosition: Geolocation["getCurrentPosition"],
|
||||
) {
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: { getCurrentPosition },
|
||||
});
|
||||
}
|
||||
|
||||
describe("NewApiaryPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(createApiary).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, "geolocation");
|
||||
});
|
||||
|
||||
it("marks only the name as required", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Notizen/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("creates the apiary with the entered values", async () => {
|
||||
vi.mocked(createApiary).mockResolvedValue({ id: "apiary-3" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Hausgarten");
|
||||
await userEvent.type(screen.getByLabelText(/Adresse/), "Dorfstraße 1");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Bienenstand anlegen" }),
|
||||
);
|
||||
|
||||
expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({
|
||||
name: "Hausgarten",
|
||||
location: undefined,
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
address: "Dorfstraße 1",
|
||||
notes: undefined,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
|
||||
});
|
||||
|
||||
it("shows an inline error and does not save without a name", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Bienenstand anlegen" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Name/)).toHaveFocus();
|
||||
expect(createApiary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fills latitude and longitude from the device location", async () => {
|
||||
mockGeolocation((success) =>
|
||||
success({
|
||||
coords: { latitude: 52.5200081, longitude: 13.4049541 },
|
||||
} as GeolocationPosition),
|
||||
);
|
||||
vi.mocked(createApiary).mockResolvedValue({ id: "apiary-4" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
|
||||
);
|
||||
expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(52.520008);
|
||||
expect(screen.getByLabelText(/Längengrad/)).toHaveValue(13.404954);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Waldrand");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Bienenstand anlegen" }),
|
||||
);
|
||||
expect(vi.mocked(createApiary).mock.calls[0][0]).toMatchObject({
|
||||
latitude: 52.520008,
|
||||
longitude: 13.404954,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains inline when the location permission is denied", async () => {
|
||||
mockGeolocation((_success, failure) =>
|
||||
failure?.({ code: 1 } as GeolocationPositionError),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
/Standort-Freigabe abgelehnt/,
|
||||
);
|
||||
expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(null);
|
||||
});
|
||||
|
||||
it("explains inline when the device has no location service", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/nicht verfügbar/);
|
||||
});
|
||||
});
|
||||
@@ -2,49 +2,120 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { LocateFixed, MapPin } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createApiary } from "@/lib/apiaries/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
ApiaryFormErrors,
|
||||
ApiaryFormValues,
|
||||
formatCoordinate,
|
||||
initialApiaryValues,
|
||||
toApiaryInput,
|
||||
validateApiary,
|
||||
} from "./apiary-form-values";
|
||||
|
||||
const PERMISSION_DENIED = 1;
|
||||
const LOCATE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export default function NewApiaryPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [latitude, setLatitude] = useState("");
|
||||
const [longitude, setLongitude] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<ApiaryFormValues>(initialApiaryValues);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ApiaryFormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locateMessage, setLocateMessage] = useState<string | null>(null);
|
||||
const draft = useFormDraft("apiary-new", values, dirty);
|
||||
|
||||
function set<K extends keyof ApiaryFormValues>(
|
||||
key: K,
|
||||
value: ApiaryFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current));
|
||||
}
|
||||
|
||||
function takeMyLocation() {
|
||||
setLocateMessage(null);
|
||||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||
setLocateMessage(
|
||||
"Standort ist auf diesem Gerät nicht verfügbar – bitte die Koordinaten selbst eintragen.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocating(false);
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
latitude: formatCoordinate(position.coords.latitude),
|
||||
longitude: formatCoordinate(position.coords.longitude),
|
||||
}));
|
||||
setDirty(true);
|
||||
},
|
||||
(positionError) => {
|
||||
setLocating(false);
|
||||
setLocateMessage(
|
||||
positionError.code === PERMISSION_DENIED
|
||||
? "Standort-Freigabe abgelehnt – du kannst sie in den Browser-Einstellungen erlauben oder die Koordinaten selbst eintragen."
|
||||
: "Standort konnte nicht ermittelt werden – bitte später erneut versuchen oder selbst eintragen.",
|
||||
);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: LOCATE_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createApiary,
|
||||
onSuccess: (apiary) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||
toast.success("Bienenstand angelegt");
|
||||
router.push(`/apiaries/${apiary.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Bienenstand konnte nicht angelegt werden.");
|
||||
onError: (mutationError) => {
|
||||
setSubmitError(
|
||||
errorMessage(mutationError, "Bienenstand konnte nicht angelegt werden."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
name,
|
||||
location: location || undefined,
|
||||
latitude: latitude ? Number(latitude) : undefined,
|
||||
longitude: longitude ? Number(longitude) : undefined,
|
||||
address: address || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
setSubmitError(null);
|
||||
const found = validateApiary(values);
|
||||
setErrors(found);
|
||||
const firstInvalid = Object.keys(found)[0];
|
||||
if (firstInvalid) {
|
||||
event.currentTarget
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate(toApiaryInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,78 +123,152 @@ export default function NewApiaryPage() {
|
||||
<PageHeader
|
||||
icon={MapPin}
|
||||
title="Neuer Bienenstand"
|
||||
description="Lege einen neuen Standort für deine Völker an."
|
||||
description="Nur der Name ist Pflicht – den genauen Ort kannst du per Knopfdruck übernehmen."
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-name">Name</Label>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Wie heißt der Stand?">
|
||||
<Field label="Name" required error={errors.name}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="apiary-name"
|
||||
id={id}
|
||||
data-field="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Standort" hint="Kurze Beschreibung, z. B. „Hinter der Scheune“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={values.location}
|
||||
onChange={(event) => set("location", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Wo genau?"
|
||||
description="Am Stand selbst einfach den Standort übernehmen."
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-location">Standort</Label>
|
||||
<Input
|
||||
id="apiary-location"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="apiary-latitude">Breitengrad</Label>
|
||||
<Input
|
||||
id="apiary-latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
inputMode="decimal"
|
||||
placeholder="z. B. 52.5200"
|
||||
value={latitude}
|
||||
onChange={(event) => setLatitude(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="apiary-longitude">Längengrad</Label>
|
||||
<Input
|
||||
id="apiary-longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
inputMode="decimal"
|
||||
placeholder="z. B. 13.4050"
|
||||
value={longitude}
|
||||
onChange={(event) => setLongitude(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-address">Adresse</Label>
|
||||
<Input
|
||||
id="apiary-address"
|
||||
value={address}
|
||||
onChange={(event) => setAddress(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="apiary-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
onClick={takeMyLocation}
|
||||
disabled={locating}
|
||||
>
|
||||
<LocateFixed aria-hidden />
|
||||
{locating ? "Standort wird ermittelt…" : "Meinen Standort übernehmen"}
|
||||
</Button>
|
||||
{locateMessage ? (
|
||||
<p role="status" className="rounded-lg bg-warning/15 p-3 text-sm">
|
||||
{locateMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Breitengrad" hint="z. B. 52.520008">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.000001"
|
||||
min={-90}
|
||||
max={90}
|
||||
value={values.latitude}
|
||||
onChange={(event) => set("latitude", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Längengrad" hint="z. B. 13.404954">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.000001"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={values.longitude}
|
||||
onChange={(event) => set("longitude", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Adresse">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoComplete="street-address"
|
||||
value={values.address}
|
||||
onChange={(event) => set("address", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={3}
|
||||
title="Notizen"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={1}
|
||||
>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Bienenstand anlegen"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CreateColonyInput, ColonyType } from "@/lib/colonies/types";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ColonyFormValues {
|
||||
code: string;
|
||||
apiaryId: string;
|
||||
colonyType: ColonyType;
|
||||
hiveType: string;
|
||||
originText: string;
|
||||
}
|
||||
|
||||
export type ColonyFormErrors = Partial<Record<keyof ColonyFormValues, string>>;
|
||||
|
||||
export function initialColonyValues(): ColonyFormValues {
|
||||
return {
|
||||
code: "",
|
||||
apiaryId: "",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "",
|
||||
originText: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Required fields in form order, so the first error is the first field. */
|
||||
export function validateColony(values: ColonyFormValues): ColonyFormErrors {
|
||||
const errors: ColonyFormErrors = {};
|
||||
if (!values.code.trim()) errors.code = "Bitte einen Code eingeben.";
|
||||
if (!values.apiaryId) errors.apiaryId = "Bitte einen Bienenstand auswählen.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
|
||||
export function toColonyInput(values: ColonyFormValues): CreateColonyInput {
|
||||
return {
|
||||
code: values.code.trim(),
|
||||
apiaryId: values.apiaryId,
|
||||
colonyType: values.colonyType,
|
||||
hiveType: textOrUndefined(values.hiveType),
|
||||
originText: textOrUndefined(values.originText),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewColonyPage from "./page";
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ createColony: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewColonyPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewColonyPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(fetchApiaries).mockResolvedValue([
|
||||
{ id: "apiary-1", name: "Hausgarten", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createColony).mockReset();
|
||||
});
|
||||
|
||||
it("marks code and apiary as required and folds the extras", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
|
||||
expect(screen.getByText(/Kurzname am Kasten/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("creates the colony with the chosen apiary", async () => {
|
||||
vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Code/), "V-07");
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
|
||||
|
||||
expect(vi.mocked(createColony).mock.calls[0][0]).toEqual({
|
||||
code: "V-07",
|
||||
apiaryId: "apiary-1",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: undefined,
|
||||
originText: undefined,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-9");
|
||||
});
|
||||
|
||||
it("shows inline errors and does not save without code and apiary", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
|
||||
|
||||
expect(screen.getByText("Bitte einen Code eingeben.")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Bitte einen Bienenstand auswählen."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Code/)).toHaveFocus();
|
||||
expect(createColony).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,16 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Layers } from "lucide-react";
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { ColonyType } from "@/lib/colonies/types";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import { Layers } from "lucide-react";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,91 +22,179 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
ColonyFormErrors,
|
||||
ColonyFormValues,
|
||||
initialColonyValues,
|
||||
toColonyInput,
|
||||
validateColony,
|
||||
} from "./colony-form-values";
|
||||
|
||||
export default function NewColonyPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: apiaries } = useQuery({
|
||||
const { data: apiaries, isLoading: apiariesLoading } = useQuery({
|
||||
queryKey: ["apiaries", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [apiaryId, setApiaryId] = useState("");
|
||||
const [colonyType, setColonyType] = useState<ColonyType>("PRODUCTION_COLONY");
|
||||
const [hiveType, setHiveType] = useState("");
|
||||
const [originText, setOriginText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<ColonyFormValues>(initialColonyValues);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ColonyFormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("colony-new", values, dirty);
|
||||
|
||||
function set<K extends keyof ColonyFormValues>(
|
||||
key: K,
|
||||
value: ColonyFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createColony,
|
||||
onSuccess: (colony) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
toast.success("Volk angelegt");
|
||||
router.push(`/colonies/${colony.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Volk konnte nicht angelegt werden.");
|
||||
onError: (mutationError) => {
|
||||
setSubmitError(
|
||||
errorMessage(mutationError, "Volk konnte nicht angelegt werden."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!apiaryId) {
|
||||
setError("Bitte einen Bienenstand auswählen.");
|
||||
setSubmitError(null);
|
||||
const found = validateColony(values);
|
||||
setErrors(found);
|
||||
const firstInvalid = Object.keys(found)[0];
|
||||
if (firstInvalid) {
|
||||
event.currentTarget
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
code,
|
||||
apiaryId,
|
||||
colonyType,
|
||||
hiveType: hiveType || undefined,
|
||||
originText: originText || undefined,
|
||||
});
|
||||
mutation.mutate(toColonyInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
const activeApiaries = apiaries ?? [];
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Layers}
|
||||
title="Neues Volk"
|
||||
description="Erfasse ein neues Volk an einem Bienenstand."
|
||||
description="Nur Code und Bienenstand sind Pflicht – der Rest kann später folgen."
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-code">Code</Label>
|
||||
<Input
|
||||
id="colony-code"
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Welches Volk, wo?">
|
||||
<Field
|
||||
label="Code"
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
error={errors.code}
|
||||
hint="Kurzname am Kasten, z. B. V-01 – muss eindeutig sein"
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="code"
|
||||
required
|
||||
autoComplete="off"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.code}
|
||||
onChange={(event) => set("code", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-apiary">Bienenstand</Label>
|
||||
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}>
|
||||
<SelectTrigger id="colony-apiary">
|
||||
<SelectValue placeholder="Bienenstand wählen" />
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Bienenstand"
|
||||
required
|
||||
error={errors.apiaryId}
|
||||
hint={
|
||||
!apiariesLoading && activeApiaries.length === 0
|
||||
? "Noch kein aktiver Bienenstand – zuerst unter „Bienenstände“ einen anlegen."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Select
|
||||
value={values.apiaryId}
|
||||
onValueChange={(value) => set("apiaryId", value ?? "")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
data-field="apiaryId"
|
||||
className="w-full"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
apiariesLoading
|
||||
? "Bienenstände werden geladen…"
|
||||
: "Bienenstand wählen"
|
||||
}
|
||||
>
|
||||
{(value: string) =>
|
||||
activeApiaries.find((apiary) => apiary.id === value)
|
||||
?.name ?? null
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(apiaries ?? []).map((apiary) => (
|
||||
{activeApiaries.map((apiary) => (
|
||||
<SelectItem key={apiary.id} value={apiary.id}>
|
||||
{apiary.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-type">Volkstyp</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Volkstyp">
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={colonyType}
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
value={values.colonyType}
|
||||
onValueChange={(value) =>
|
||||
set("colonyType", value as ColonyType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="colony-type">
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue>
|
||||
{COLONY_TYPE_LABELS[values.colonyType]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
@@ -114,31 +204,59 @@ export default function NewColonyPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-hive-type">Beutentyp</Label>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={2}
|
||||
>
|
||||
<Field label="Beutentyp" hint="z. B. Zander, Dadant">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="colony-hive-type"
|
||||
value={hiveType}
|
||||
onChange={(event) => setHiveType(event.target.value)}
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={values.hiveType}
|
||||
onChange={(event) => set("hiveType", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-origin">Herkunft</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="colony-origin"
|
||||
value={originText}
|
||||
onChange={(event) => setOriginText(event.target.value)}
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={values.originText}
|
||||
onChange={(event) => set("originText", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Volk anlegen"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CreateEquipmentInput } from "@/lib/equipment/types";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface EquipmentFormValues {
|
||||
name: string;
|
||||
inventoryNumber: string;
|
||||
location: string;
|
||||
manufacturer: string;
|
||||
purchaseDate: string;
|
||||
maintenanceDate: string;
|
||||
}
|
||||
|
||||
export type EquipmentFormErrors = Partial<
|
||||
Record<keyof EquipmentFormValues, string>
|
||||
>;
|
||||
|
||||
export function initialEquipmentValues(): EquipmentFormValues {
|
||||
return {
|
||||
name: "",
|
||||
inventoryNumber: "",
|
||||
location: "",
|
||||
manufacturer: "",
|
||||
purchaseDate: "",
|
||||
maintenanceDate: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Required fields in form order, so the first error is the first field. */
|
||||
export function validateEquipment(
|
||||
values: EquipmentFormValues,
|
||||
): EquipmentFormErrors {
|
||||
const errors: EquipmentFormErrors = {};
|
||||
if (!values.name.trim()) errors.name = "Bitte eine Bezeichnung eingeben.";
|
||||
if (!values.inventoryNumber.trim()) {
|
||||
errors.inventoryNumber = "Bitte eine Inventarnummer eingeben.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
const dateOrUndefined = (value: string) =>
|
||||
value ? new Date(value).toISOString() : undefined;
|
||||
|
||||
export function toEquipmentInput(
|
||||
values: EquipmentFormValues,
|
||||
): CreateEquipmentInput {
|
||||
return {
|
||||
inventoryNumber: values.inventoryNumber.trim(),
|
||||
name: values.name.trim(),
|
||||
manufacturer: textOrUndefined(values.manufacturer),
|
||||
purchaseDate: dateOrUndefined(values.purchaseDate),
|
||||
location: textOrUndefined(values.location),
|
||||
maintenanceDate: dateOrUndefined(values.maintenanceDate),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewEquipmentPage from "./page";
|
||||
import { createEquipment } from "@/lib/equipment/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/lib/equipment/api", () => ({ createEquipment: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewEquipmentPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewEquipmentPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(createEquipment).mockReset();
|
||||
});
|
||||
|
||||
it("marks name and inventory number as required", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("creates the item with the entered values", async () => {
|
||||
vi.mocked(createEquipment).mockResolvedValue({ id: "eq-1" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Bezeichnung/), "Schleuder");
|
||||
await userEvent.type(screen.getByLabelText(/Inventarnummer/), "INV-7");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Gegenstand anlegen" }),
|
||||
);
|
||||
|
||||
expect(vi.mocked(createEquipment).mock.calls[0][0]).toEqual({
|
||||
inventoryNumber: "INV-7",
|
||||
name: "Schleuder",
|
||||
manufacturer: undefined,
|
||||
purchaseDate: undefined,
|
||||
location: undefined,
|
||||
maintenanceDate: undefined,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/equipment/eq-1");
|
||||
});
|
||||
|
||||
it("shows inline errors, focuses the first and does not save", async () => {
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Bezeichnung/), "Schleuder");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Gegenstand anlegen" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Bitte eine Inventarnummer eingeben."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Bitte eine Bezeichnung eingeben."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Inventarnummer/)).toHaveFocus();
|
||||
expect(createEquipment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,49 +3,83 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { createEquipment } from "@/lib/equipment/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
EquipmentFormErrors,
|
||||
EquipmentFormValues,
|
||||
initialEquipmentValues,
|
||||
toEquipmentInput,
|
||||
validateEquipment,
|
||||
} from "./equipment-form-values";
|
||||
|
||||
export default function NewEquipmentPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [inventoryNumber, setInventoryNumber] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [maintenanceDate, setMaintenanceDate] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<EquipmentFormValues>(
|
||||
initialEquipmentValues,
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<EquipmentFormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("equipment-new", values, dirty);
|
||||
|
||||
function set<K extends keyof EquipmentFormValues>(
|
||||
key: K,
|
||||
value: EquipmentFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createEquipment,
|
||||
onSuccess: (equipment) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
toast.success("Gegenstand angelegt");
|
||||
router.push(`/equipment/${equipment.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Gegenstand konnte nicht angelegt werden.");
|
||||
onError: (mutationError) => {
|
||||
setSubmitError(
|
||||
errorMessage(mutationError, "Gegenstand konnte nicht angelegt werden."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
inventoryNumber,
|
||||
name,
|
||||
manufacturer: manufacturer || undefined,
|
||||
purchaseDate: purchaseDate ? new Date(purchaseDate).toISOString() : undefined,
|
||||
location: location || undefined,
|
||||
maintenanceDate: maintenanceDate
|
||||
? new Date(maintenanceDate).toISOString()
|
||||
: undefined,
|
||||
});
|
||||
setSubmitError(null);
|
||||
const found = validateEquipment(values);
|
||||
setErrors(found);
|
||||
const firstInvalid = Object.keys(found)[0];
|
||||
if (firstInvalid) {
|
||||
event.currentTarget
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate(toEquipmentInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -53,72 +87,135 @@ export default function NewEquipmentPage() {
|
||||
<PageHeader
|
||||
icon={Wrench}
|
||||
title="Neuer Inventargegenstand"
|
||||
description="Erfasse einen neuen Gegenstand für dein Inventar."
|
||||
description="Nur Bezeichnung und Inventarnummer sind Pflicht."
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-inventory-number">Inventarnummer</Label>
|
||||
<Input
|
||||
id="equipment-inventory-number"
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was ist es?">
|
||||
<Field
|
||||
label="Bezeichnung"
|
||||
required
|
||||
value={inventoryNumber}
|
||||
onChange={(event) => setInventoryNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-name">Bezeichnung</Label>
|
||||
error={errors.name}
|
||||
hint="z. B. „Honigschleuder 4 Waben“"
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="equipment-name"
|
||||
id={id}
|
||||
data-field="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-manufacturer">Hersteller</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Inventarnummer"
|
||||
required
|
||||
error={errors.inventoryNumber}
|
||||
hint="Deine eigene Nummer, z. B. vom Aufkleber am Gerät"
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="equipment-manufacturer"
|
||||
value={manufacturer}
|
||||
onChange={(event) => setManufacturer(event.target.value)}
|
||||
id={id}
|
||||
data-field="inventoryNumber"
|
||||
required
|
||||
autoComplete="off"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.inventoryNumber}
|
||||
onChange={(event) =>
|
||||
set("inventoryNumber", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-purchase-date">Kaufdatum</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Standort" hint="Wo steht es, z. B. „Keller“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="equipment-purchase-date"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={values.location}
|
||||
onChange={(event) => set("location", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={3}
|
||||
>
|
||||
<Field label="Hersteller">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={values.manufacturer}
|
||||
onChange={(event) => set("manufacturer", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Kaufdatum">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
value={purchaseDate}
|
||||
onChange={(event) => setPurchaseDate(event.target.value)}
|
||||
value={values.purchaseDate}
|
||||
onChange={(event) => set("purchaseDate", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-location">Standort</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Wartungsdatum">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="equipment-location"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="equipment-maintenance-date">Wartungsdatum</Label>
|
||||
<Input
|
||||
id="equipment-maintenance-date"
|
||||
id={id}
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(event) => setMaintenanceDate(event.target.value)}
|
||||
value={values.maintenanceDate}
|
||||
onChange={(event) =>
|
||||
set("maintenanceDate", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Gegenstand anlegen"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { createFeeding } from "@/lib/feedings/api";
|
||||
import type { FeedType } from "@/lib/feedings/types";
|
||||
|
||||
/** Common units as one tap each; the API still receives a plain string. */
|
||||
export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "kg", label: "kg" },
|
||||
{ value: "l", label: "l" },
|
||||
{ value: "Waben", label: "Waben" },
|
||||
];
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface FeedingFormValues {
|
||||
colonyId: string;
|
||||
date: string;
|
||||
feedType: FeedType;
|
||||
amount: string;
|
||||
unit: string;
|
||||
product: string;
|
||||
batchNumber: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function initialFeedingValues(colonyId: string): FeedingFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
date: todayIsoDate(),
|
||||
feedType: "SUGAR_WATER",
|
||||
amount: "",
|
||||
unit: "l",
|
||||
product: "",
|
||||
batchNumber: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
|
||||
export function toFeedingInput(
|
||||
values: FeedingFormValues,
|
||||
): Parameters<typeof createFeeding>[0] {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: new Date(values.date).toISOString(),
|
||||
feedType: values.feedType,
|
||||
amount: Number(values.amount),
|
||||
unit: values.unit,
|
||||
product: textOrUndefined(values.product),
|
||||
batchNumber: textOrUndefined(values.batchNumber),
|
||||
notes: textOrUndefined(values.notes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewFeedingPage from "./page";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewFeedingPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewFeedingPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(fetchColonies).mockResolvedValue([
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createFeeding).mockReset();
|
||||
});
|
||||
|
||||
it("marks what is required and keeps the extra fields folded", () => {
|
||||
renderPage();
|
||||
// Volk, Datum, Futterart, Menge, Einheit
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(5);
|
||||
expect(screen.queryByLabelText(/Produkt/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("sends feed type, amount and the tapped unit", async () => {
|
||||
vi.mocked(createFeeding).mockResolvedValue({
|
||||
id: "f-1",
|
||||
colonyId: "colony-1",
|
||||
} as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Futterteig" }));
|
||||
await userEvent.type(screen.getByLabelText(/^Menge/), "2.5");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "kg" }));
|
||||
expect(screen.getByRole("radio", { name: "kg" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Fütterung speichern" }),
|
||||
);
|
||||
|
||||
const [input, options] = vi.mocked(createFeeding).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
colonyId: "colony-1",
|
||||
feedType: "FEED_PASTE",
|
||||
amount: 2.5,
|
||||
unit: "kg",
|
||||
});
|
||||
expect(input.product).toBeUndefined();
|
||||
expect(options?.idempotencyKey).toBeTruthy();
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("shows an inline error and focuses the amount when it is missing", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Fütterung speichern" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Bitte eine Menge angeben.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Menge/)).toHaveFocus();
|
||||
expect(createFeeding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,14 @@ import { toast } from "sonner";
|
||||
import { Utensils } from "lucide-react";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { FeedType } from "@/lib/feedings/types";
|
||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { queueFeeding } from "@/lib/offline/db";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -25,31 +25,119 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FEEDING_UNIT_OPTIONS,
|
||||
FeedingFormValues,
|
||||
initialFeedingValues,
|
||||
toFeedingInput,
|
||||
} from "./feeding-form-values";
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
type RequiredKey = "colonyId" | "date" | "amount";
|
||||
type FieldErrors = Partial<Record<RequiredKey, string>>;
|
||||
|
||||
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "amount"];
|
||||
|
||||
function validate(values: FeedingFormValues): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
if (!values.date) errors.date = "Bitte ein Datum angeben.";
|
||||
if (!values.amount.trim()) {
|
||||
errors.amount = "Bitte eine Menge angeben.";
|
||||
} else if (Number(values.amount) < 0) {
|
||||
errors.amount = "Die Menge kann nicht negativ sein.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
interface ChoiceGroupProps<T extends string> {
|
||||
id: string;
|
||||
labelledBy: string;
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** One large button per option: a single tap with gloves, no dropdown. */
|
||||
function ChoiceGroup<T extends string>({
|
||||
id,
|
||||
labelledBy,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ChoiceGroupProps<T>) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="radiogroup"
|
||||
aria-labelledby={labelledBy}
|
||||
className={cn("grid gap-2", className)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary font-medium text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FEED_TYPE_OPTIONS = FEED_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: FEED_TYPE_LABELS[type],
|
||||
}));
|
||||
|
||||
export default function NewFeedingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: colonies } = useQuery({
|
||||
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
const activeColonies =
|
||||
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
|
||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
||||
const [date, setDate] = useState(todayIsoDate());
|
||||
const [feedType, setFeedType] = useState<FeedType>("SUGAR_WATER");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [unit, setUnit] = useState("l");
|
||||
const [product, setProduct] = useState("");
|
||||
const [batchNumber, setBatchNumber] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<FeedingFormValues>(() =>
|
||||
initialFeedingValues(searchParams.get("colonyId") ?? ""),
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("feeding-new", values, dirty);
|
||||
|
||||
function set<K extends keyof FeedingFormValues>(
|
||||
key: K,
|
||||
value: FeedingFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
// One key per opened form: a double tap or an offline retry of the
|
||||
// same entry is recognised by the server instead of saved twice.
|
||||
@@ -58,6 +146,7 @@ export default function NewFeedingPage() {
|
||||
mutationFn: (input: Parameters<typeof createFeeding>[0]) =>
|
||||
createFeeding(input, { idempotencyKey }),
|
||||
onSuccess: (feeding) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
||||
});
|
||||
@@ -69,11 +158,19 @@ export default function NewFeedingPage() {
|
||||
},
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
setError("Fütterung konnte nicht gespeichert werden.");
|
||||
setSubmitError(
|
||||
errorMessage(
|
||||
mutationError,
|
||||
"Fütterung konnte nicht gespeichert werden.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void queueFeeding(variables, idempotencyKey).then(() => {
|
||||
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
|
||||
draft.clear();
|
||||
toast.info(
|
||||
"Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
router.push(`/colonies/${variables.colonyId}`);
|
||||
});
|
||||
},
|
||||
@@ -81,25 +178,25 @@ export default function NewFeedingPage() {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!colonyId) {
|
||||
setError("Bitte ein Volk auswählen.");
|
||||
setSubmitError(null);
|
||||
const errors = validate(values);
|
||||
setFieldErrors(errors);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||
if (firstInvalid) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
if (!amount) {
|
||||
setError("Bitte eine Menge angeben.");
|
||||
return;
|
||||
mutation.mutate(toFeedingInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
mutation.mutate({
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
feedType,
|
||||
amount: Number(amount),
|
||||
unit,
|
||||
product: product || undefined,
|
||||
batchNumber: batchNumber || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -107,16 +204,52 @@ export default function NewFeedingPage() {
|
||||
<PageHeader
|
||||
icon={Utensils}
|
||||
title="Neue Fütterung"
|
||||
description="Dokumentiere eine Fütterung für ein Volk."
|
||||
description="Nur Volk, Datum, Futterart und Menge sind Pflicht."
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-colony">Volk</Label>
|
||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||
<SelectTrigger id="feeding-colony" className="h-11 w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||
<Field
|
||||
label="Volk"
|
||||
required
|
||||
error={fieldErrors.colonyId}
|
||||
hint={
|
||||
!coloniesLoading && activeColonies.length === 0
|
||||
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ describedBy, invalid }) => (
|
||||
<Select
|
||||
value={values.colonyId}
|
||||
onValueChange={(value) => set("colonyId", value ?? "")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="feeding-colony"
|
||||
data-field="colonyId"
|
||||
className="h-11 w-full"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
coloniesLoading
|
||||
? "Völker werden geladen…"
|
||||
: "Volk wählen"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeColonies.map((colony) => (
|
||||
@@ -126,95 +259,132 @@ export default function NewFeedingPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-date">Datum</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="feeding-date"
|
||||
id={id}
|
||||
data-field="date"
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.date}
|
||||
onChange={(event) => set("date", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-type">Futterart</Label>
|
||||
<Select
|
||||
value={feedType}
|
||||
onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)}
|
||||
>
|
||||
<SelectTrigger id="feeding-type" className="h-11 w-full">
|
||||
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FEED_TYPES.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{FEED_TYPE_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-amount">Menge</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Futterart" required>
|
||||
{({ id, labelId }) => (
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
options={FEED_TYPE_OPTIONS}
|
||||
value={values.feedType}
|
||||
onChange={(feedType) => set("feedType", feedType)}
|
||||
className="grid-cols-2 sm:grid-cols-3"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Menge" required error={fieldErrors.amount}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="feeding-amount"
|
||||
id={id}
|
||||
data-field="amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="any"
|
||||
required
|
||||
className="h-11"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.amount}
|
||||
onChange={(event) => set("amount", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-unit">Einheit</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Einheit" required>
|
||||
{({ id, labelId }) => (
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
options={FEEDING_UNIT_OPTIONS}
|
||||
value={values.unit}
|
||||
onChange={(unit) => set("unit", unit)}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={3}
|
||||
>
|
||||
<Field label="Produkt" hint="Handelsname, z. B. „Apiinvert“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="feeding-unit"
|
||||
required
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
value={values.product}
|
||||
onChange={(event) => set("product", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-product">Produkt</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Chargennummer"
|
||||
hint="Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="feeding-product"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
value={values.batchNumber}
|
||||
onChange={(event) => set("batchNumber", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="feeding-batch"
|
||||
className="h-11"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-notes">Notizen</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id="feeding-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Fütterung speichern"}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Fütterung speichern"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewProductPage from "./page";
|
||||
import { createProduct } from "@/lib/products/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/lib/products/api", () => ({ createProduct: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewProductPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewProductPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(createProduct).mockReset();
|
||||
});
|
||||
|
||||
it("marks only the name as required and folds the extras", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(1);
|
||||
expect(screen.queryByLabelText(/SKU/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("creates the product with sizes in grams", async () => {
|
||||
vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
await userEvent.type(screen.getByLabelText(/Gebindegröße/), "500");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
);
|
||||
expect(screen.getByText("Artikelnummer für Shop/Lager")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/SKU/), "FT-500");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
|
||||
expect(vi.mocked(createProduct).mock.calls[0][0]).toEqual({
|
||||
name: "Frühtracht",
|
||||
description: undefined,
|
||||
sku: "FT-500",
|
||||
packagingSize: 500,
|
||||
weight: undefined,
|
||||
salesUnit: undefined,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/products");
|
||||
});
|
||||
|
||||
it("shows an inline error and does not save without a name", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
|
||||
expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Name/)).toHaveFocus();
|
||||
expect(createProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,48 +3,82 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ShoppingBag } from "lucide-react";
|
||||
import { createProduct } from "@/lib/products/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
ProductFormErrors,
|
||||
ProductFormValues,
|
||||
initialProductValues,
|
||||
toProductInput,
|
||||
validateProduct,
|
||||
} from "./product-form-values";
|
||||
|
||||
export default function NewProductPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sku, setSku] = useState("");
|
||||
const [packagingSize, setPackagingSize] = useState("");
|
||||
const [weight, setWeight] = useState("");
|
||||
const [salesUnit, setSalesUnit] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<ProductFormValues>(initialProductValues);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ProductFormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("product-new", values, dirty);
|
||||
|
||||
function set<K extends keyof ProductFormValues>(
|
||||
key: K,
|
||||
value: ProductFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createProduct,
|
||||
onSuccess: () => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
toast.success("Produkt angelegt");
|
||||
router.push("/products");
|
||||
},
|
||||
onError: () => {
|
||||
setError("Produkt konnte nicht angelegt werden.");
|
||||
onError: (mutationError) => {
|
||||
setSubmitError(
|
||||
errorMessage(mutationError, "Produkt konnte nicht angelegt werden."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
sku: sku || undefined,
|
||||
packagingSize: packagingSize ? Number(packagingSize) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
salesUnit: salesUnit || undefined,
|
||||
});
|
||||
setSubmitError(null);
|
||||
const found = validateProduct(values);
|
||||
setErrors(found);
|
||||
const firstInvalid = Object.keys(found)[0];
|
||||
if (firstInvalid) {
|
||||
event.currentTarget
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate(toProductInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,73 +86,138 @@ export default function NewProductPage() {
|
||||
<PageHeader
|
||||
icon={ShoppingBag}
|
||||
title="Neues Produkt"
|
||||
description="Lege ein neues verkaufsfertiges Produkt an."
|
||||
description="Nur der Name ist Pflicht – alles andere kannst du später ergänzen."
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-name">Name</Label>
|
||||
<Input
|
||||
id="product-name"
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was verkaufst du?">
|
||||
<Field
|
||||
label="Name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
error={errors.name}
|
||||
hint="z. B. „Frühtracht 500 g“"
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="name"
|
||||
required
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-description">Beschreibung</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={values.salesUnit}
|
||||
onChange={(event) => set("salesUnit", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Gebindegröße (g)"
|
||||
hint="Füllmenge in Gramm, z. B. 500"
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={values.packagingSize}
|
||||
onChange={(event) =>
|
||||
set("packagingSize", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={3}
|
||||
>
|
||||
<Field
|
||||
label="Gewicht (g)"
|
||||
hint="in Gramm"
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={values.weight}
|
||||
onChange={(event) => set("weight", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="SKU" hint="Artikelnummer für Shop/Lager">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
autoComplete="off"
|
||||
value={values.sku}
|
||||
onChange={(event) => set("sku", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Beschreibung">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id="product-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
id={id}
|
||||
value={values.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-sku">SKU</Label>
|
||||
<Input
|
||||
id="product-sku"
|
||||
value={sku}
|
||||
onChange={(event) => setSku(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-packaging-size">Gebindegröße</Label>
|
||||
<Input
|
||||
id="product-packaging-size"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={packagingSize}
|
||||
onChange={(event) => setPackagingSize(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-weight">Gewicht</Label>
|
||||
<Input
|
||||
id="product-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={weight}
|
||||
onChange={(event) => setWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-sales-unit">Verkaufseinheit</Label>
|
||||
<Input
|
||||
id="product-sales-unit"
|
||||
placeholder="z. B. Glas, Flasche"
|
||||
value={salesUnit}
|
||||
onChange={(event) => setSalesUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Produkt anlegen"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CreateProductInput } from "@/lib/products/types";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ProductFormValues {
|
||||
name: string;
|
||||
salesUnit: string;
|
||||
packagingSize: string;
|
||||
weight: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>;
|
||||
|
||||
export function initialProductValues(): ProductFormValues {
|
||||
return {
|
||||
name: "",
|
||||
salesUnit: "",
|
||||
packagingSize: "",
|
||||
weight: "",
|
||||
sku: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Required fields in form order, so the first error is the first field. */
|
||||
export function validateProduct(values: ProductFormValues): ProductFormErrors {
|
||||
const errors: ProductFormErrors = {};
|
||||
if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
const numberOrUndefined = (value: string) =>
|
||||
value.trim() ? Number(value) : undefined;
|
||||
|
||||
export function toProductInput(values: ProductFormValues): CreateProductInput {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
description: textOrUndefined(values.description),
|
||||
sku: textOrUndefined(values.sku),
|
||||
packagingSize: numberOrUndefined(values.packagingSize),
|
||||
weight: numberOrUndefined(values.weight),
|
||||
salesUnit: textOrUndefined(values.salesUnit),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewTreatmentPage from "./page";
|
||||
import { createTreatment } from "@/lib/treatments/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewTreatmentPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewTreatmentPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
push.mockReset();
|
||||
vi.mocked(fetchColonies).mockResolvedValue([
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createTreatment).mockReset();
|
||||
});
|
||||
|
||||
it("marks colony, date and product as required and keeps the Bestandsbuch visible", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("Pflicht")).toHaveLength(3);
|
||||
expect(screen.getByText("Für das Bestandsbuch")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Art\. 108 VO \(EU\) 2019\/6/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText(/Lieferant \(Name, Anschrift\)/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Grund/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("sends the treatment with its Bestandsbuch details", async () => {
|
||||
vi.mocked(createTreatment).mockResolvedValue({
|
||||
id: "t-1",
|
||||
colonyId: "colony-1",
|
||||
} as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
|
||||
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "Oxalsäure");
|
||||
await userEvent.type(screen.getByLabelText(/Wartezeit/), "0");
|
||||
await userEvent.type(
|
||||
screen.getByLabelText(/Lieferant/),
|
||||
"Imkereibedarf Muster",
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Behandlung speichern" }),
|
||||
);
|
||||
|
||||
const [input, options] = vi.mocked(createTreatment).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
colonyId: "colony-1",
|
||||
product: "Oxuvar",
|
||||
activeIngredient: "Oxalsäure",
|
||||
withdrawalPeriodDays: 0,
|
||||
supplier: "Imkereibedarf Muster",
|
||||
});
|
||||
expect(input.startDate).toBeUndefined();
|
||||
expect(options?.idempotencyKey).toBeTruthy();
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("shows an inline error and focuses the product field when it is missing", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Behandlung speichern" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Bitte das Mittel angeben.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Mittel/)).toHaveFocus();
|
||||
expect(createTreatment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,10 @@ import { createTreatment } from "@/lib/treatments/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { queueTreatment } from "@/lib/offline/db";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -23,9 +24,27 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
TreatmentFormValues,
|
||||
initialTreatmentValues,
|
||||
toTreatmentInput,
|
||||
} from "./treatment-form-values";
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
type RequiredKey = "colonyId" | "date" | "product";
|
||||
type FieldErrors = Partial<Record<RequiredKey, string>>;
|
||||
|
||||
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "product"];
|
||||
|
||||
function validate(values: TreatmentFormValues): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
if (!values.date) errors.date = "Bitte ein Datum angeben.";
|
||||
if (!values.product.trim()) errors.product = "Bitte das Mittel angeben.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default function NewTreatmentPage() {
|
||||
@@ -33,28 +52,31 @@ export default function NewTreatmentPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: colonies } = useQuery({
|
||||
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
const activeColonies =
|
||||
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
|
||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
||||
const [date, setDate] = useState(todayIsoDate());
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [product, setProduct] = useState("");
|
||||
const [activeIngredient, setActiveIngredient] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [batchNumber, setBatchNumber] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
||||
const [supplier, setSupplier] = useState("");
|
||||
const [purchaseProof, setPurchaseProof] = useState("");
|
||||
const [prescribingVet, setPrescribingVet] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<TreatmentFormValues>(() =>
|
||||
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("treatment-new", values, dirty);
|
||||
|
||||
function set<K extends keyof TreatmentFormValues>(
|
||||
key: K,
|
||||
value: TreatmentFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
// One key per opened form: a double tap or an offline retry of the
|
||||
// same entry is recognised by the server instead of saved twice.
|
||||
@@ -63,6 +85,7 @@ export default function NewTreatmentPage() {
|
||||
mutationFn: (input: Parameters<typeof createTreatment>[0]) =>
|
||||
createTreatment(input, { idempotencyKey }),
|
||||
onSuccess: (treatment) => {
|
||||
draft.clear();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
||||
});
|
||||
@@ -71,11 +94,19 @@ export default function NewTreatmentPage() {
|
||||
},
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
setError("Behandlung konnte nicht gespeichert werden.");
|
||||
setSubmitError(
|
||||
errorMessage(
|
||||
mutationError,
|
||||
"Behandlung konnte nicht gespeichert werden.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void queueTreatment(variables, idempotencyKey).then(() => {
|
||||
toast.info("Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.");
|
||||
draft.clear();
|
||||
toast.info(
|
||||
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
router.push(`/colonies/${variables.colonyId}`);
|
||||
});
|
||||
},
|
||||
@@ -83,28 +114,25 @@ export default function NewTreatmentPage() {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!colonyId) {
|
||||
setError("Bitte ein Volk auswählen.");
|
||||
setSubmitError(null);
|
||||
const errors = validate(values);
|
||||
setFieldErrors(errors);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||
if (firstInvalid) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
||||
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
||||
product,
|
||||
activeIngredient: activeIngredient || undefined,
|
||||
amount: amount || undefined,
|
||||
method: method || undefined,
|
||||
reason: reason || undefined,
|
||||
batchNumber: batchNumber || undefined,
|
||||
notes: notes || undefined,
|
||||
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
||||
supplier: supplier.trim() || undefined,
|
||||
purchaseProof: purchaseProof.trim() || undefined,
|
||||
prescribingVet: prescribingVet.trim() || undefined,
|
||||
});
|
||||
mutation.mutate(toTreatmentInput(values));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -112,17 +140,53 @@ export default function NewTreatmentPage() {
|
||||
<PageHeader
|
||||
icon={Syringe}
|
||||
title="Neue Behandlung"
|
||||
description="Dokumentiere eine Behandlung für ein Volk."
|
||||
description="Nur Volk, Datum und Mittel sind Pflicht."
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
savedAt={draft.pending.savedAt}
|
||||
onRestore={restoreDraft}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-colony">Volk</Label>
|
||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||
<SelectTrigger id="treatment-colony" className="h-11 w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||
<Field
|
||||
label="Volk"
|
||||
required
|
||||
error={fieldErrors.colonyId}
|
||||
hint={
|
||||
!coloniesLoading && activeColonies.length === 0
|
||||
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ describedBy, invalid }) => (
|
||||
<Select
|
||||
value={values.colonyId}
|
||||
onValueChange={(value) => set("colonyId", value ?? "")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="treatment-colony"
|
||||
data-field="colonyId"
|
||||
className="h-11 w-full"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
coloniesLoading
|
||||
? "Völker werden geladen…"
|
||||
: "Volk wählen"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeColonies.map((colony) => (
|
||||
@@ -132,161 +196,237 @@ export default function NewTreatmentPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-date">Datum</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="treatment-date"
|
||||
id={id}
|
||||
data-field="date"
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.date}
|
||||
onChange={(event) => set("date", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-start-date">Beginn</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Mittel"
|
||||
required
|
||||
error={fieldErrors.product}
|
||||
hint="Handelsname, z. B. „Oxuvar“"
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id="treatment-start-date"
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-end-date">Ende</Label>
|
||||
<Input
|
||||
id="treatment-end-date"
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-product">Mittel</Label>
|
||||
<Input
|
||||
id="treatment-product"
|
||||
id={id}
|
||||
data-field="product"
|
||||
required
|
||||
className="h-11"
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.product}
|
||||
onChange={(event) => set("product", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-active-ingredient">Wirkstoff</Label>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Mittel & Anwendung">
|
||||
<Field
|
||||
label="Wirkstoff"
|
||||
hint="Steht auf der Packung, z. B. Ameisensäure."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="treatment-active-ingredient"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={activeIngredient}
|
||||
onChange={(event) => setActiveIngredient(event.target.value)}
|
||||
value={values.activeIngredient}
|
||||
onChange={(event) =>
|
||||
set("activeIngredient", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-amount">Menge</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Menge" hint="z. B. „50 ml“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="treatment-amount"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
value={values.amount}
|
||||
onChange={(event) => set("amount", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-method">Methode</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Methode" hint="z. B. Träufeln, Verdunsten">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="treatment-method"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value)}
|
||||
value={values.method}
|
||||
onChange={(event) => set("method", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-reason">Grund</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Chargennummer"
|
||||
hint="Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="treatment-reason"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
value={values.batchNumber}
|
||||
onChange={(event) => set("batchNumber", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-batch">Chargennummer</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Wartezeit (Tage)"
|
||||
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id="treatment-batch"
|
||||
className="h-11"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-withdrawal">Wartezeit (Tage)</Label>
|
||||
<Input
|
||||
id="treatment-withdrawal"
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={withdrawalPeriodDays}
|
||||
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
||||
value={values.withdrawalPeriodDays}
|
||||
onChange={(event) =>
|
||||
set("withdrawalPeriodDays", event.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig
|
||||
geerntet werden.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
|
||||
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-supplier">Lieferant (Name, Anschrift)</Label>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={3}
|
||||
title="Für das Bestandsbuch"
|
||||
description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
|
||||
>
|
||||
<Field label="Lieferant (Name, Anschrift)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="treatment-supplier"
|
||||
id={id}
|
||||
className="h-11"
|
||||
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
|
||||
value={supplier}
|
||||
onChange={(event) => setSupplier(event.target.value)}
|
||||
value={values.supplier}
|
||||
onChange={(event) => set("supplier", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-proof">Beleg für den Erwerb</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Beleg für den Erwerb">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="treatment-proof"
|
||||
id={id}
|
||||
className="h-11"
|
||||
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
|
||||
value={purchaseProof}
|
||||
onChange={(event) => setPurchaseProof(event.target.value)}
|
||||
value={values.purchaseProof}
|
||||
onChange={(event) =>
|
||||
set("purchaseProof", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Verschreibender Tierarzt (falls zutreffend)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id="treatment-vet"
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={prescribingVet}
|
||||
onChange={(event) => setPrescribingVet(event.target.value)}
|
||||
value={values.prescribingVet}
|
||||
onChange={(event) =>
|
||||
set("prescribingVet", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-notes">Notizen</Label>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={4}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={4}
|
||||
>
|
||||
<Field
|
||||
label="Beginn"
|
||||
hint="Nur bei Behandlungen über mehrere Tage."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={values.startDate}
|
||||
onChange={(event) => set("startDate", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Ende">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={values.endDate}
|
||||
onChange={(event) => set("endDate", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Grund">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.reason}
|
||||
onChange={(event) => set("reason", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id="treatment-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Behandlung speichern"}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Behandlung speichern"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { createTreatment } from "@/lib/treatments/api";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface TreatmentFormValues {
|
||||
colonyId: string;
|
||||
date: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
product: string;
|
||||
activeIngredient: string;
|
||||
amount: string;
|
||||
method: string;
|
||||
reason: string;
|
||||
batchNumber: string;
|
||||
notes: string;
|
||||
withdrawalPeriodDays: string;
|
||||
supplier: string;
|
||||
purchaseProof: string;
|
||||
prescribingVet: string;
|
||||
}
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
date: todayIsoDate(),
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
product: "",
|
||||
activeIngredient: "",
|
||||
amount: "",
|
||||
method: "",
|
||||
reason: "",
|
||||
batchNumber: "",
|
||||
notes: "",
|
||||
withdrawalPeriodDays: "",
|
||||
supplier: "",
|
||||
purchaseProof: "",
|
||||
prescribingVet: "",
|
||||
};
|
||||
}
|
||||
|
||||
const textOrUndefined = (value: string) =>
|
||||
value.trim() ? value.trim() : undefined;
|
||||
const dateOrUndefined = (value: string) =>
|
||||
value ? new Date(value).toISOString() : undefined;
|
||||
|
||||
export function toTreatmentInput(
|
||||
values: TreatmentFormValues,
|
||||
): Parameters<typeof createTreatment>[0] {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: new Date(values.date).toISOString(),
|
||||
startDate: dateOrUndefined(values.startDate),
|
||||
endDate: dateOrUndefined(values.endDate),
|
||||
product: values.product.trim(),
|
||||
activeIngredient: textOrUndefined(values.activeIngredient),
|
||||
amount: textOrUndefined(values.amount),
|
||||
method: textOrUndefined(values.method),
|
||||
reason: textOrUndefined(values.reason),
|
||||
batchNumber: textOrUndefined(values.batchNumber),
|
||||
notes: textOrUndefined(values.notes),
|
||||
withdrawalPeriodDays: values.withdrawalPeriodDays.trim()
|
||||
? Number(values.withdrawalPeriodDays)
|
||||
: undefined,
|
||||
supplier: textOrUndefined(values.supplier),
|
||||
purchaseProof: textOrUndefined(values.purchaseProof),
|
||||
prescribingVet: textOrUndefined(values.prescribingVet),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user