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
+2326
-685
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 { FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { MapPin } from "lucide-react";
|
import { LocateFixed, MapPin } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { createApiary } from "@/lib/apiaries/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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() {
|
export default function NewApiaryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState("");
|
const [values, setValues] = useState<ApiaryFormValues>(initialApiaryValues);
|
||||||
const [location, setLocation] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [latitude, setLatitude] = useState("");
|
const [errors, setErrors] = useState<ApiaryFormErrors>({});
|
||||||
const [longitude, setLongitude] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [address, setAddress] = useState("");
|
const [locating, setLocating] = useState(false);
|
||||||
const [notes, setNotes] = useState("");
|
const [locateMessage, setLocateMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = 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({
|
const mutation = useMutation({
|
||||||
mutationFn: createApiary,
|
mutationFn: createApiary,
|
||||||
onSuccess: (apiary) => {
|
onSuccess: (apiary) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||||
|
toast.success("Bienenstand angelegt");
|
||||||
router.push(`/apiaries/${apiary.id}`);
|
router.push(`/apiaries/${apiary.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Bienenstand konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Bienenstand konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const found = validateApiary(values);
|
||||||
name,
|
setErrors(found);
|
||||||
location: location || undefined,
|
const firstInvalid = Object.keys(found)[0];
|
||||||
latitude: latitude ? Number(latitude) : undefined,
|
if (firstInvalid) {
|
||||||
longitude: longitude ? Number(longitude) : undefined,
|
event.currentTarget
|
||||||
address: address || undefined,
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
notes: notes || undefined,
|
?.focus();
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
mutation.mutate(toApiaryInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,78 +123,152 @@ export default function NewApiaryPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
title="Neuer Bienenstand"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="apiary-name">Name</Label>
|
onSubmit={handleSubmit}
|
||||||
<Input
|
noValidate
|
||||||
id="apiary-name"
|
>
|
||||||
required
|
<FormSection step={1} title="Wie heißt der Stand?">
|
||||||
value={name}
|
<Field label="Name" required error={errors.name}>
|
||||||
onChange={(event) => setName(event.target.value)}
|
{({ id, describedBy, invalid }) => (
|
||||||
/>
|
<Input
|
||||||
</div>
|
id={id}
|
||||||
<div className="flex flex-col gap-2">
|
data-field="name"
|
||||||
<Label htmlFor="apiary-location">Standort</Label>
|
required
|
||||||
<Input
|
aria-describedby={describedBy}
|
||||||
id="apiary-location"
|
aria-invalid={invalid}
|
||||||
value={location}
|
value={values.name}
|
||||||
onChange={(event) => setLocation(event.target.value)}
|
onChange={(event) => set("name", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex gap-3">
|
</Field>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<Field label="Standort" hint="Kurze Beschreibung, z. B. „Hinter der Scheune“">
|
||||||
<Label htmlFor="apiary-latitude">Breitengrad</Label>
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="apiary-latitude"
|
id={id}
|
||||||
type="number"
|
aria-describedby={describedBy}
|
||||||
step="0.000001"
|
value={values.location}
|
||||||
inputMode="decimal"
|
onChange={(event) => set("location", event.target.value)}
|
||||||
placeholder="z. B. 52.5200"
|
/>
|
||||||
value={latitude}
|
)}
|
||||||
onChange={(event) => setLatitude(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">
|
||||||
|
<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}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<Label htmlFor="apiary-longitude">Längengrad</Label>
|
<Field label="Breitengrad" hint="z. B. 52.520008">
|
||||||
<Input
|
{({ id, describedBy }) => (
|
||||||
id="apiary-longitude"
|
<Input
|
||||||
type="number"
|
id={id}
|
||||||
step="0.000001"
|
aria-describedby={describedBy}
|
||||||
inputMode="decimal"
|
type="number"
|
||||||
placeholder="z. B. 13.4050"
|
inputMode="decimal"
|
||||||
value={longitude}
|
step="0.000001"
|
||||||
onChange={(event) => setLongitude(event.target.value)}
|
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>
|
</div>
|
||||||
</div>
|
<Field label="Adresse">
|
||||||
<div className="flex flex-col gap-2">
|
{({ id }) => (
|
||||||
<Label htmlFor="apiary-address">Adresse</Label>
|
<Input
|
||||||
<Input
|
id={id}
|
||||||
id="apiary-address"
|
autoComplete="street-address"
|
||||||
value={address}
|
value={values.address}
|
||||||
onChange={(event) => setAddress(event.target.value)}
|
onChange={(event) => set("address", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="apiary-notes">Notizen</Label>
|
</FormSection>
|
||||||
<Textarea
|
|
||||||
id="apiary-notes"
|
<FormSection
|
||||||
value={notes}
|
step={3}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
title="Notizen"
|
||||||
/>
|
collapsible
|
||||||
</div>
|
defaultOpen={false}
|
||||||
{error ? (
|
fieldCount={1}
|
||||||
<p role="alert" className="text-sm text-destructive">
|
>
|
||||||
{error}
|
<Field label="Notizen">
|
||||||
</p>
|
{({ id }) => (
|
||||||
) : null}
|
<Textarea
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
id={id}
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
value={values.notes}
|
||||||
</Button>
|
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>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Layers } from "lucide-react";
|
||||||
import { createColony } from "@/lib/colonies/api";
|
import { createColony } from "@/lib/colonies/api";
|
||||||
import { ColonyType } from "@/lib/colonies/types";
|
import { ColonyType } from "@/lib/colonies/types";
|
||||||
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||||
import { Layers } from "lucide-react";
|
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -20,125 +22,241 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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() {
|
export default function NewColonyPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: apiaries } = useQuery({
|
const { data: apiaries, isLoading: apiariesLoading } = useQuery({
|
||||||
queryKey: ["apiaries", { status: "ACTIVE" }],
|
queryKey: ["apiaries", { status: "ACTIVE" }],
|
||||||
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [code, setCode] = useState("");
|
const [values, setValues] = useState<ColonyFormValues>(initialColonyValues);
|
||||||
const [apiaryId, setApiaryId] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [colonyType, setColonyType] = useState<ColonyType>("PRODUCTION_COLONY");
|
const [errors, setErrors] = useState<ColonyFormErrors>({});
|
||||||
const [hiveType, setHiveType] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [originText, setOriginText] = useState("");
|
const draft = useFormDraft("colony-new", values, dirty);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createColony,
|
mutationFn: createColony,
|
||||||
onSuccess: (colony) => {
|
onSuccess: (colony) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||||
|
toast.success("Volk angelegt");
|
||||||
router.push(`/colonies/${colony.id}`);
|
router.push(`/colonies/${colony.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Volk konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Volk konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!apiaryId) {
|
const found = validateColony(values);
|
||||||
setError("Bitte einen Bienenstand auswählen.");
|
setErrors(found);
|
||||||
|
const firstInvalid = Object.keys(found)[0];
|
||||||
|
if (firstInvalid) {
|
||||||
|
event.currentTarget
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutation.mutate({
|
mutation.mutate(toColonyInput(values));
|
||||||
code,
|
|
||||||
apiaryId,
|
|
||||||
colonyType,
|
|
||||||
hiveType: hiveType || undefined,
|
|
||||||
originText: originText || undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeApiaries = apiaries ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
title="Neues Volk"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="colony-code">Code</Label>
|
onSubmit={handleSubmit}
|
||||||
<Input
|
noValidate
|
||||||
id="colony-code"
|
>
|
||||||
|
<FormSection step={1} title="Welches Volk, wo?">
|
||||||
|
<Field
|
||||||
|
label="Code"
|
||||||
required
|
required
|
||||||
value={code}
|
error={errors.code}
|
||||||
onChange={(event) => setCode(event.target.value)}
|
hint="Kurzname am Kasten, z. B. V-01 – muss eindeutig sein"
|
||||||
/>
|
|
||||||
</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" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(apiaries ?? []).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>
|
|
||||||
<Select
|
|
||||||
value={colonyType}
|
|
||||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="colony-type">
|
{({ id, describedBy, invalid }) => (
|
||||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
<Input
|
||||||
</SelectTrigger>
|
id={id}
|
||||||
<SelectContent>
|
data-field="code"
|
||||||
{COLONY_TYPES.map((type) => (
|
required
|
||||||
<SelectItem key={type} value={type}>
|
autoComplete="off"
|
||||||
{COLONY_TYPE_LABELS[type]}
|
aria-describedby={describedBy}
|
||||||
</SelectItem>
|
aria-invalid={invalid}
|
||||||
))}
|
value={values.code}
|
||||||
</SelectContent>
|
onChange={(event) => set("code", event.target.value)}
|
||||||
</Select>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="colony-hive-type">Beutentyp</Label>
|
<Field
|
||||||
<Input
|
label="Bienenstand"
|
||||||
id="colony-hive-type"
|
required
|
||||||
value={hiveType}
|
error={errors.apiaryId}
|
||||||
onChange={(event) => setHiveType(event.target.value)}
|
hint={
|
||||||
/>
|
!apiariesLoading && activeApiaries.length === 0
|
||||||
</div>
|
? "Noch kein aktiver Bienenstand – zuerst unter „Bienenstände“ einen anlegen."
|
||||||
<div className="flex flex-col gap-2">
|
: undefined
|
||||||
<Label htmlFor="colony-origin">Herkunft</Label>
|
}
|
||||||
<Input
|
>
|
||||||
id="colony-origin"
|
{({ id, describedBy, invalid }) => (
|
||||||
value={originText}
|
<Select
|
||||||
onChange={(event) => setOriginText(event.target.value)}
|
value={values.apiaryId}
|
||||||
/>
|
onValueChange={(value) => set("apiaryId", value ?? "")}
|
||||||
</div>
|
>
|
||||||
{error ? (
|
<SelectTrigger
|
||||||
<p role="alert" className="text-sm text-destructive">
|
id={id}
|
||||||
{error}
|
data-field="apiaryId"
|
||||||
</p>
|
className="w-full"
|
||||||
) : null}
|
aria-describedby={describedBy}
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
aria-invalid={invalid}
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
>
|
||||||
</Button>
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
apiariesLoading
|
||||||
|
? "Bienenstände werden geladen…"
|
||||||
|
: "Bienenstand wählen"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(value: string) =>
|
||||||
|
activeApiaries.find((apiary) => apiary.id === value)
|
||||||
|
?.name ?? null
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{activeApiaries.map((apiary) => (
|
||||||
|
<SelectItem key={apiary.id} value={apiary.id}>
|
||||||
|
{apiary.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Volkstyp">
|
||||||
|
{({ id }) => (
|
||||||
|
<Select
|
||||||
|
value={values.colonyType}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
set("colonyType", value as ColonyType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={id} className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{COLONY_TYPE_LABELS[values.colonyType]}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COLONY_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{COLONY_TYPE_LABELS[type]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</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={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
value={values.hiveType}
|
||||||
|
onChange={(event) => set("hiveType", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
value={values.originText}
|
||||||
|
onChange={(event) => set("originText", 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…" : "Volk anlegen"}
|
||||||
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Wrench } from "lucide-react";
|
import { Wrench } from "lucide-react";
|
||||||
import { createEquipment } from "@/lib/equipment/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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() {
|
export default function NewEquipmentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [inventoryNumber, setInventoryNumber] = useState("");
|
const [values, setValues] = useState<EquipmentFormValues>(
|
||||||
const [name, setName] = useState("");
|
initialEquipmentValues,
|
||||||
const [manufacturer, setManufacturer] = useState("");
|
);
|
||||||
const [purchaseDate, setPurchaseDate] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [location, setLocation] = useState("");
|
const [errors, setErrors] = useState<EquipmentFormErrors>({});
|
||||||
const [maintenanceDate, setMaintenanceDate] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [error, setError] = 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({
|
const mutation = useMutation({
|
||||||
mutationFn: createEquipment,
|
mutationFn: createEquipment,
|
||||||
onSuccess: (equipment) => {
|
onSuccess: (equipment) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||||
|
toast.success("Gegenstand angelegt");
|
||||||
router.push(`/equipment/${equipment.id}`);
|
router.push(`/equipment/${equipment.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Gegenstand konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Gegenstand konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const found = validateEquipment(values);
|
||||||
inventoryNumber,
|
setErrors(found);
|
||||||
name,
|
const firstInvalid = Object.keys(found)[0];
|
||||||
manufacturer: manufacturer || undefined,
|
if (firstInvalid) {
|
||||||
purchaseDate: purchaseDate ? new Date(purchaseDate).toISOString() : undefined,
|
event.currentTarget
|
||||||
location: location || undefined,
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
maintenanceDate: maintenanceDate
|
?.focus();
|
||||||
? new Date(maintenanceDate).toISOString()
|
return;
|
||||||
: undefined,
|
}
|
||||||
});
|
mutation.mutate(toEquipmentInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,72 +87,135 @@ export default function NewEquipmentPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
title="Neuer Inventargegenstand"
|
title="Neuer Inventargegenstand"
|
||||||
description="Erfasse einen neuen Gegenstand für dein Inventar."
|
description="Nur Bezeichnung und Inventarnummer sind Pflicht."
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="equipment-inventory-number">Inventarnummer</Label>
|
onSubmit={handleSubmit}
|
||||||
<Input
|
noValidate
|
||||||
id="equipment-inventory-number"
|
>
|
||||||
|
<FormSection step={1} title="Was ist es?">
|
||||||
|
<Field
|
||||||
|
label="Bezeichnung"
|
||||||
required
|
required
|
||||||
value={inventoryNumber}
|
error={errors.name}
|
||||||
onChange={(event) => setInventoryNumber(event.target.value)}
|
hint="z. B. „Honigschleuder 4 Waben“"
|
||||||
/>
|
>
|
||||||
</div>
|
{({ id, describedBy, invalid }) => (
|
||||||
<div className="flex flex-col gap-2">
|
<Input
|
||||||
<Label htmlFor="equipment-name">Bezeichnung</Label>
|
id={id}
|
||||||
<Input
|
data-field="name"
|
||||||
id="equipment-name"
|
required
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
value={values.name}
|
||||||
|
onChange={(event) => set("name", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Inventarnummer"
|
||||||
required
|
required
|
||||||
value={name}
|
error={errors.inventoryNumber}
|
||||||
onChange={(event) => setName(event.target.value)}
|
hint="Deine eigene Nummer, z. B. vom Aufkleber am Gerät"
|
||||||
/>
|
>
|
||||||
</div>
|
{({ id, describedBy, invalid }) => (
|
||||||
<div className="flex flex-col gap-2">
|
<Input
|
||||||
<Label htmlFor="equipment-manufacturer">Hersteller</Label>
|
id={id}
|
||||||
<Input
|
data-field="inventoryNumber"
|
||||||
id="equipment-manufacturer"
|
required
|
||||||
value={manufacturer}
|
autoComplete="off"
|
||||||
onChange={(event) => setManufacturer(event.target.value)}
|
aria-describedby={describedBy}
|
||||||
/>
|
aria-invalid={invalid}
|
||||||
</div>
|
value={values.inventoryNumber}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={(event) =>
|
||||||
<Label htmlFor="equipment-purchase-date">Kaufdatum</Label>
|
set("inventoryNumber", event.target.value)
|
||||||
<Input
|
}
|
||||||
id="equipment-purchase-date"
|
/>
|
||||||
type="date"
|
)}
|
||||||
value={purchaseDate}
|
</Field>
|
||||||
onChange={(event) => setPurchaseDate(event.target.value)}
|
<Field label="Standort" hint="Wo steht es, z. B. „Keller“">
|
||||||
/>
|
{({ id, describedBy }) => (
|
||||||
</div>
|
<Input
|
||||||
<div className="flex flex-col gap-2">
|
id={id}
|
||||||
<Label htmlFor="equipment-location">Standort</Label>
|
aria-describedby={describedBy}
|
||||||
<Input
|
value={values.location}
|
||||||
id="equipment-location"
|
onChange={(event) => set("location", event.target.value)}
|
||||||
value={location}
|
/>
|
||||||
onChange={(event) => setLocation(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
</FormSection>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="equipment-maintenance-date">Wartungsdatum</Label>
|
<FormSection
|
||||||
<Input
|
step={2}
|
||||||
id="equipment-maintenance-date"
|
title="Weitere Angaben"
|
||||||
type="date"
|
collapsible
|
||||||
value={maintenanceDate}
|
defaultOpen={false}
|
||||||
onChange={(event) => setMaintenanceDate(event.target.value)}
|
fieldCount={3}
|
||||||
/>
|
>
|
||||||
</div>
|
<Field label="Hersteller">
|
||||||
{error ? (
|
{({ id }) => (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<Input
|
||||||
{error}
|
id={id}
|
||||||
</p>
|
value={values.manufacturer}
|
||||||
) : null}
|
onChange={(event) => set("manufacturer", event.target.value)}
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
/>
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
)}
|
||||||
</Button>
|
</Field>
|
||||||
|
<Field label="Kaufdatum">
|
||||||
|
{({ id }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="date"
|
||||||
|
value={values.purchaseDate}
|
||||||
|
onChange={(event) => set("purchaseDate", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Wartungsdatum">
|
||||||
|
{({ id }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="date"
|
||||||
|
value={values.maintenanceDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
set("maintenanceDate", 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…" : "Gegenstand anlegen"}
|
||||||
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Utensils } from "lucide-react";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { createFeeding } from "@/lib/feedings/api";
|
import { createFeeding } from "@/lib/feedings/api";
|
||||||
import { FeedType } from "@/lib/feedings/types";
|
|
||||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueFeeding } from "@/lib/offline/db";
|
import { queueFeeding } from "@/lib/offline/db";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,31 +25,119 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 {
|
type RequiredKey = "colonyId" | "date" | "amount";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewFeedingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: colonies } = useQuery({
|
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||||
queryKey: ["colonies"],
|
queryKey: ["colonies"],
|
||||||
queryFn: () => fetchColonies(),
|
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 [values, setValues] = useState<FeedingFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialFeedingValues(searchParams.get("colonyId") ?? ""),
|
||||||
const [feedType, setFeedType] = useState<FeedType>("SUGAR_WATER");
|
);
|
||||||
const [amount, setAmount] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [unit, setUnit] = useState("l");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [product, setProduct] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [batchNumber, setBatchNumber] = useState("");
|
const draft = useFormDraft("feeding-new", values, dirty);
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// 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.
|
// 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]) =>
|
mutationFn: (input: Parameters<typeof createFeeding>[0]) =>
|
||||||
createFeeding(input, { idempotencyKey }),
|
createFeeding(input, { idempotencyKey }),
|
||||||
onSuccess: (feeding) => {
|
onSuccess: (feeding) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
||||||
});
|
});
|
||||||
@@ -69,11 +158,19 @@ export default function NewFeedingPage() {
|
|||||||
},
|
},
|
||||||
onError: (mutationError, variables) => {
|
onError: (mutationError, variables) => {
|
||||||
if (mutationError instanceof ApiError) {
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Fütterung konnte nicht gespeichert werden.");
|
setSubmitError(
|
||||||
|
errorMessage(
|
||||||
|
mutationError,
|
||||||
|
"Fütterung konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueFeeding(variables, idempotencyKey).then(() => {
|
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}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -81,25 +178,25 @@ export default function NewFeedingPage() {
|
|||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!colonyId) {
|
const errors = validate(values);
|
||||||
setError("Bitte ein Volk auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!amount) {
|
mutation.mutate(toFeedingInput(values));
|
||||||
setError("Bitte eine Menge angeben.");
|
}
|
||||||
return;
|
|
||||||
|
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 (
|
return (
|
||||||
@@ -107,114 +204,187 @@ export default function NewFeedingPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Utensils}
|
icon={Utensils}
|
||||||
title="Neue Fütterung"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="feeding-colony">Volk</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
noValidate
|
||||||
<SelectTrigger id="feeding-colony" className="h-11 w-full">
|
>
|
||||||
<SelectValue placeholder="Volk wählen" />
|
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||||
</SelectTrigger>
|
<Field
|
||||||
<SelectContent>
|
label="Volk"
|
||||||
{activeColonies.map((colony) => (
|
|
||||||
<SelectItem key={colony.id} value={colony.id}>
|
|
||||||
{colony.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="feeding-date">Datum</Label>
|
|
||||||
<Input
|
|
||||||
id="feeding-date"
|
|
||||||
type="date"
|
|
||||||
required
|
required
|
||||||
className="h-11"
|
error={fieldErrors.colonyId}
|
||||||
value={date}
|
hint={
|
||||||
onChange={(event) => setDate(event.target.value)}
|
!coloniesLoading && activeColonies.length === 0
|
||||||
/>
|
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
|
||||||
</div>
|
: undefined
|
||||||
<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">
|
{({ describedBy, invalid }) => (
|
||||||
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
|
<Select
|
||||||
</SelectTrigger>
|
value={values.colonyId}
|
||||||
<SelectContent>
|
onValueChange={(value) => set("colonyId", value ?? "")}
|
||||||
{FEED_TYPES.map((option) => (
|
>
|
||||||
<SelectItem key={option} value={option}>
|
<SelectTrigger
|
||||||
{FEED_TYPE_LABELS[option]}
|
id="feeding-colony"
|
||||||
</SelectItem>
|
data-field="colonyId"
|
||||||
))}
|
className="h-11 w-full"
|
||||||
</SelectContent>
|
aria-describedby={describedBy}
|
||||||
</Select>
|
aria-invalid={invalid}
|
||||||
</div>
|
>
|
||||||
<div className="flex gap-3">
|
<SelectValue
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
placeholder={
|
||||||
<Label htmlFor="feeding-amount">Menge</Label>
|
coloniesLoading
|
||||||
<Input
|
? "Völker werden geladen…"
|
||||||
id="feeding-amount"
|
: "Volk wählen"
|
||||||
type="number"
|
}
|
||||||
inputMode="decimal"
|
/>
|
||||||
min={0}
|
</SelectTrigger>
|
||||||
required
|
<SelectContent>
|
||||||
className="h-11"
|
{activeColonies.map((colony) => (
|
||||||
value={amount}
|
<SelectItem key={colony.id} value={colony.id}>
|
||||||
onChange={(event) => setAmount(event.target.value)}
|
{colony.code}
|
||||||
/>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
</SelectContent>
|
||||||
<Label htmlFor="feeding-unit">Einheit</Label>
|
</Select>
|
||||||
<Input
|
)}
|
||||||
id="feeding-unit"
|
</Field>
|
||||||
required
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
className="h-11"
|
{({ id, describedBy, invalid }) => (
|
||||||
value={unit}
|
<Input
|
||||||
onChange={(event) => setUnit(event.target.value)}
|
id={id}
|
||||||
/>
|
data-field="date"
|
||||||
</div>
|
type="date"
|
||||||
</div>
|
required
|
||||||
<div className="flex flex-col gap-2">
|
className="h-11"
|
||||||
<Label htmlFor="feeding-product">Produkt</Label>
|
aria-describedby={describedBy}
|
||||||
<Input
|
aria-invalid={invalid}
|
||||||
id="feeding-product"
|
value={values.date}
|
||||||
className="h-11"
|
onChange={(event) => set("date", event.target.value)}
|
||||||
value={product}
|
/>
|
||||||
onChange={(event) => setProduct(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
<Field label="Futterart" required>
|
||||||
<div className="flex flex-col gap-2">
|
{({ id, labelId }) => (
|
||||||
<Label htmlFor="feeding-batch">Chargennummer</Label>
|
<ChoiceGroup
|
||||||
<Input
|
id={id}
|
||||||
id="feeding-batch"
|
labelledBy={labelId}
|
||||||
className="h-11"
|
options={FEED_TYPE_OPTIONS}
|
||||||
value={batchNumber}
|
value={values.feedType}
|
||||||
onChange={(event) => setBatchNumber(event.target.value)}
|
onChange={(feedType) => set("feedType", feedType)}
|
||||||
/>
|
className="grid-cols-2 sm:grid-cols-3"
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<Label htmlFor="feeding-notes">Notizen</Label>
|
</Field>
|
||||||
<Textarea
|
<Field label="Menge" required error={fieldErrors.amount}>
|
||||||
id="feeding-notes"
|
{({ id, describedBy, invalid }) => (
|
||||||
value={notes}
|
<Input
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
id={id}
|
||||||
/>
|
data-field="amount"
|
||||||
</div>
|
type="number"
|
||||||
{error ? (
|
inputMode="decimal"
|
||||||
<p role="alert" className="text-sm text-destructive">
|
min={0}
|
||||||
{error}
|
step="any"
|
||||||
</p>
|
required
|
||||||
) : null}
|
className="h-11"
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
aria-describedby={describedBy}
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Fütterung speichern"}
|
aria-invalid={invalid}
|
||||||
</Button>
|
value={values.amount}
|
||||||
|
onChange={(event) => set("amount", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className="h-11"
|
||||||
|
value={values.product}
|
||||||
|
onChange={(event) => set("product", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Chargennummer"
|
||||||
|
hint="Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className="h-11"
|
||||||
|
value={values.batchNumber}
|
||||||
|
onChange={(event) => set("batchNumber", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<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 gespeichert…"
|
||||||
|
: "Fütterung speichern"}
|
||||||
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShoppingBag } from "lucide-react";
|
import { ShoppingBag } from "lucide-react";
|
||||||
import { createProduct } from "@/lib/products/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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() {
|
export default function NewProductPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState("");
|
const [values, setValues] = useState<ProductFormValues>(initialProductValues);
|
||||||
const [description, setDescription] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [sku, setSku] = useState("");
|
const [errors, setErrors] = useState<ProductFormErrors>({});
|
||||||
const [packagingSize, setPackagingSize] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [weight, setWeight] = useState("");
|
const draft = useFormDraft("product-new", values, dirty);
|
||||||
const [salesUnit, setSalesUnit] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createProduct,
|
mutationFn: createProduct,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||||
|
toast.success("Produkt angelegt");
|
||||||
router.push("/products");
|
router.push("/products");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Produkt konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Produkt konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const found = validateProduct(values);
|
||||||
name,
|
setErrors(found);
|
||||||
description: description || undefined,
|
const firstInvalid = Object.keys(found)[0];
|
||||||
sku: sku || undefined,
|
if (firstInvalid) {
|
||||||
packagingSize: packagingSize ? Number(packagingSize) : undefined,
|
event.currentTarget
|
||||||
weight: weight ? Number(weight) : undefined,
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
salesUnit: salesUnit || undefined,
|
?.focus();
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
mutation.mutate(toProductInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,73 +86,138 @@ export default function NewProductPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={ShoppingBag}
|
icon={ShoppingBag}
|
||||||
title="Neues Produkt"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="product-name">Name</Label>
|
onSubmit={handleSubmit}
|
||||||
<Input
|
noValidate
|
||||||
id="product-name"
|
>
|
||||||
|
<FormSection step={1} title="Was verkaufst du?">
|
||||||
|
<Field
|
||||||
|
label="Name"
|
||||||
required
|
required
|
||||||
value={name}
|
error={errors.name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
hint="z. B. „Frühtracht 500 g“"
|
||||||
/>
|
>
|
||||||
</div>
|
{({ id, describedBy, invalid }) => (
|
||||||
<div className="flex flex-col gap-2">
|
<Input
|
||||||
<Label htmlFor="product-description">Beschreibung</Label>
|
id={id}
|
||||||
<Textarea
|
data-field="name"
|
||||||
id="product-description"
|
required
|
||||||
value={description}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
aria-invalid={invalid}
|
||||||
/>
|
value={values.name}
|
||||||
</div>
|
onChange={(event) => set("name", event.target.value)}
|
||||||
<div className="flex flex-col gap-2">
|
/>
|
||||||
<Label htmlFor="product-sku">SKU</Label>
|
)}
|
||||||
<Input
|
</Field>
|
||||||
id="product-sku"
|
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
|
||||||
value={sku}
|
{({ id, describedBy }) => (
|
||||||
onChange={(event) => setSku(event.target.value)}
|
<Input
|
||||||
/>
|
id={id}
|
||||||
</div>
|
aria-describedby={describedBy}
|
||||||
<div className="flex flex-col gap-2">
|
value={values.salesUnit}
|
||||||
<Label htmlFor="product-packaging-size">Gebindegröße</Label>
|
onChange={(event) => set("salesUnit", event.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="product-packaging-size"
|
)}
|
||||||
type="number"
|
</Field>
|
||||||
step="0.01"
|
<Field
|
||||||
value={packagingSize}
|
label="Gebindegröße (g)"
|
||||||
onChange={(event) => setPackagingSize(event.target.value)}
|
hint="Füllmenge in Gramm, z. B. 500"
|
||||||
/>
|
>
|
||||||
</div>
|
{({ id, describedBy }) => (
|
||||||
<div className="flex flex-col gap-2">
|
<Input
|
||||||
<Label htmlFor="product-weight">Gewicht</Label>
|
id={id}
|
||||||
<Input
|
aria-describedby={describedBy}
|
||||||
id="product-weight"
|
type="number"
|
||||||
type="number"
|
inputMode="decimal"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={weight}
|
min={0}
|
||||||
onChange={(event) => setWeight(event.target.value)}
|
value={values.packagingSize}
|
||||||
/>
|
onChange={(event) =>
|
||||||
</div>
|
set("packagingSize", event.target.value)
|
||||||
<div className="flex flex-col gap-2">
|
}
|
||||||
<Label htmlFor="product-sales-unit">Verkaufseinheit</Label>
|
/>
|
||||||
<Input
|
)}
|
||||||
id="product-sales-unit"
|
</Field>
|
||||||
placeholder="z. B. Glas, Flasche"
|
</FormSection>
|
||||||
value={salesUnit}
|
|
||||||
onChange={(event) => setSalesUnit(event.target.value)}
|
<FormSection
|
||||||
/>
|
step={2}
|
||||||
</div>
|
title="Weitere Angaben"
|
||||||
{error ? (
|
collapsible
|
||||||
<p role="alert" className="text-sm text-destructive">
|
defaultOpen={false}
|
||||||
{error}
|
fieldCount={3}
|
||||||
</p>
|
>
|
||||||
) : null}
|
<Field
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
label="Gewicht (g)"
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
hint="in Gramm"
|
||||||
</Button>
|
>
|
||||||
|
{({ 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={id}
|
||||||
|
value={values.description}
|
||||||
|
onChange={(event) => set("description", 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…" : "Produkt anlegen"}
|
||||||
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueTreatment } from "@/lib/offline/db";
|
import { queueTreatment } from "@/lib/offline/db";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -23,9 +24,27 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 {
|
type RequiredKey = "colonyId" | "date" | "product";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewTreatmentPage() {
|
||||||
@@ -33,28 +52,31 @@ export default function NewTreatmentPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: colonies } = useQuery({
|
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||||
queryKey: ["colonies"],
|
queryKey: ["colonies"],
|
||||||
queryFn: () => fetchColonies(),
|
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 [values, setValues] = useState<TreatmentFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
|
||||||
const [startDate, setStartDate] = useState("");
|
);
|
||||||
const [endDate, setEndDate] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [product, setProduct] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [activeIngredient, setActiveIngredient] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [amount, setAmount] = useState("");
|
const draft = useFormDraft("treatment-new", values, dirty);
|
||||||
const [method, setMethod] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
function set<K extends keyof TreatmentFormValues>(
|
||||||
const [batchNumber, setBatchNumber] = useState("");
|
key: K,
|
||||||
const [notes, setNotes] = useState("");
|
value: TreatmentFormValues[K],
|
||||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
) {
|
||||||
const [supplier, setSupplier] = useState("");
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
const [purchaseProof, setPurchaseProof] = useState("");
|
setDirty(true);
|
||||||
const [prescribingVet, setPrescribingVet] = useState("");
|
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One key per opened form: a double tap or an offline retry of the
|
// 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.
|
// 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]) =>
|
mutationFn: (input: Parameters<typeof createTreatment>[0]) =>
|
||||||
createTreatment(input, { idempotencyKey }),
|
createTreatment(input, { idempotencyKey }),
|
||||||
onSuccess: (treatment) => {
|
onSuccess: (treatment) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
||||||
});
|
});
|
||||||
@@ -71,11 +94,19 @@ export default function NewTreatmentPage() {
|
|||||||
},
|
},
|
||||||
onError: (mutationError, variables) => {
|
onError: (mutationError, variables) => {
|
||||||
if (mutationError instanceof ApiError) {
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Behandlung konnte nicht gespeichert werden.");
|
setSubmitError(
|
||||||
|
errorMessage(
|
||||||
|
mutationError,
|
||||||
|
"Behandlung konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueTreatment(variables, idempotencyKey).then(() => {
|
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}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -83,28 +114,25 @@ export default function NewTreatmentPage() {
|
|||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!colonyId) {
|
const errors = validate(values);
|
||||||
setError("Bitte ein Volk auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutation.mutate({
|
mutation.mutate(toTreatmentInput(values));
|
||||||
colonyId,
|
}
|
||||||
date: new Date(date).toISOString(),
|
|
||||||
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
function restoreDraft() {
|
||||||
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
const restored = draft.restore();
|
||||||
product,
|
if (restored) {
|
||||||
activeIngredient: activeIngredient || undefined,
|
setValues(restored);
|
||||||
amount: amount || undefined,
|
setDirty(true);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,181 +140,293 @@ export default function NewTreatmentPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Syringe}
|
icon={Syringe}
|
||||||
title="Neue Behandlung"
|
title="Neue Behandlung"
|
||||||
description="Dokumentiere eine Behandlung für ein Volk."
|
description="Nur Volk, Datum und Mittel sind Pflicht."
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="treatment-colony">Volk</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
noValidate
|
||||||
<SelectTrigger id="treatment-colony" className="h-11 w-full">
|
>
|
||||||
<SelectValue placeholder="Volk wählen" />
|
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||||
</SelectTrigger>
|
<Field
|
||||||
<SelectContent>
|
label="Volk"
|
||||||
{activeColonies.map((colony) => (
|
|
||||||
<SelectItem key={colony.id} value={colony.id}>
|
|
||||||
{colony.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="treatment-date">Datum</Label>
|
|
||||||
<Input
|
|
||||||
id="treatment-date"
|
|
||||||
type="date"
|
|
||||||
required
|
required
|
||||||
className="h-11"
|
error={fieldErrors.colonyId}
|
||||||
value={date}
|
hint={
|
||||||
onChange={(event) => setDate(event.target.value)}
|
!coloniesLoading && activeColonies.length === 0
|
||||||
/>
|
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
|
||||||
</div>
|
: undefined
|
||||||
<div className="flex flex-col gap-2">
|
}
|
||||||
<Label htmlFor="treatment-start-date">Beginn</Label>
|
>
|
||||||
<Input
|
{({ describedBy, invalid }) => (
|
||||||
id="treatment-start-date"
|
<Select
|
||||||
type="date"
|
value={values.colonyId}
|
||||||
className="h-11"
|
onValueChange={(value) => set("colonyId", value ?? "")}
|
||||||
value={startDate}
|
>
|
||||||
onChange={(event) => setStartDate(event.target.value)}
|
<SelectTrigger
|
||||||
/>
|
id="treatment-colony"
|
||||||
</div>
|
data-field="colonyId"
|
||||||
<div className="flex flex-col gap-2">
|
className="h-11 w-full"
|
||||||
<Label htmlFor="treatment-end-date">Ende</Label>
|
aria-describedby={describedBy}
|
||||||
<Input
|
aria-invalid={invalid}
|
||||||
id="treatment-end-date"
|
>
|
||||||
type="date"
|
<SelectValue
|
||||||
className="h-11"
|
placeholder={
|
||||||
value={endDate}
|
coloniesLoading
|
||||||
onChange={(event) => setEndDate(event.target.value)}
|
? "Völker werden geladen…"
|
||||||
/>
|
: "Volk wählen"
|
||||||
</div>
|
}
|
||||||
<div className="flex flex-col gap-2">
|
/>
|
||||||
<Label htmlFor="treatment-product">Mittel</Label>
|
</SelectTrigger>
|
||||||
<Input
|
<SelectContent>
|
||||||
id="treatment-product"
|
{activeColonies.map((colony) => (
|
||||||
|
<SelectItem key={colony.id} value={colony.id}>
|
||||||
|
{colony.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
|
{({ id, describedBy, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
data-field="date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
className="h-11"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
value={values.date}
|
||||||
|
onChange={(event) => set("date", event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Mittel"
|
||||||
required
|
required
|
||||||
className="h-11"
|
error={fieldErrors.product}
|
||||||
value={product}
|
hint="Handelsname, z. B. „Oxuvar“"
|
||||||
onChange={(event) => setProduct(event.target.value)}
|
>
|
||||||
/>
|
{({ id, describedBy, invalid }) => (
|
||||||
</div>
|
<Input
|
||||||
<div className="flex flex-col gap-2">
|
id={id}
|
||||||
<Label htmlFor="treatment-active-ingredient">Wirkstoff</Label>
|
data-field="product"
|
||||||
<Input
|
required
|
||||||
id="treatment-active-ingredient"
|
className="h-11"
|
||||||
className="h-11"
|
aria-describedby={describedBy}
|
||||||
value={activeIngredient}
|
aria-invalid={invalid}
|
||||||
onChange={(event) => setActiveIngredient(event.target.value)}
|
value={values.product}
|
||||||
/>
|
onChange={(event) => set("product", event.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<Label htmlFor="treatment-amount">Menge</Label>
|
</Field>
|
||||||
<Input
|
</FormSection>
|
||||||
id="treatment-amount"
|
|
||||||
className="h-11"
|
<FormSection step={2} title="Mittel & Anwendung">
|
||||||
value={amount}
|
<Field
|
||||||
onChange={(event) => setAmount(event.target.value)}
|
label="Wirkstoff"
|
||||||
/>
|
hint="Steht auf der Packung, z. B. Ameisensäure."
|
||||||
</div>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
{({ id, describedBy }) => (
|
||||||
<Label htmlFor="treatment-method">Methode</Label>
|
<Input
|
||||||
<Input
|
id={id}
|
||||||
id="treatment-method"
|
aria-describedby={describedBy}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={method}
|
value={values.activeIngredient}
|
||||||
onChange={(event) => setMethod(event.target.value)}
|
onChange={(event) =>
|
||||||
/>
|
set("activeIngredient", event.target.value)
|
||||||
</div>
|
}
|
||||||
<div className="flex flex-col gap-2">
|
/>
|
||||||
<Label htmlFor="treatment-reason">Grund</Label>
|
)}
|
||||||
<Input
|
</Field>
|
||||||
id="treatment-reason"
|
<Field label="Menge" hint="z. B. „50 ml“">
|
||||||
className="h-11"
|
{({ id, describedBy }) => (
|
||||||
value={reason}
|
<Input
|
||||||
onChange={(event) => setReason(event.target.value)}
|
id={id}
|
||||||
/>
|
aria-describedby={describedBy}
|
||||||
</div>
|
className="h-11"
|
||||||
<div className="flex flex-col gap-2">
|
value={values.amount}
|
||||||
<Label htmlFor="treatment-batch">Chargennummer</Label>
|
onChange={(event) => set("amount", event.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="treatment-batch"
|
)}
|
||||||
className="h-11"
|
</Field>
|
||||||
value={batchNumber}
|
<Field label="Methode" hint="z. B. Träufeln, Verdunsten">
|
||||||
onChange={(event) => setBatchNumber(event.target.value)}
|
{({ id, describedBy }) => (
|
||||||
/>
|
<Input
|
||||||
</div>
|
id={id}
|
||||||
<div className="flex flex-col gap-2">
|
aria-describedby={describedBy}
|
||||||
<Label htmlFor="treatment-withdrawal">Wartezeit (Tage)</Label>
|
className="h-11"
|
||||||
<Input
|
value={values.method}
|
||||||
id="treatment-withdrawal"
|
onChange={(event) => set("method", event.target.value)}
|
||||||
type="number"
|
/>
|
||||||
inputMode="numeric"
|
)}
|
||||||
min={0}
|
</Field>
|
||||||
className="h-11"
|
<Field
|
||||||
value={withdrawalPeriodDays}
|
label="Chargennummer"
|
||||||
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
hint="Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."
|
||||||
/>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
{({ id, describedBy }) => (
|
||||||
Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig
|
<Input
|
||||||
geerntet werden.
|
id={id}
|
||||||
</p>
|
aria-describedby={describedBy}
|
||||||
</div>
|
className="h-11"
|
||||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
value={values.batchNumber}
|
||||||
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
|
onChange={(event) => set("batchNumber", event.target.value)}
|
||||||
<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.
|
</Field>
|
||||||
</p>
|
<Field
|
||||||
<div className="flex flex-col gap-2">
|
label="Wartezeit (Tage)"
|
||||||
<Label htmlFor="treatment-supplier">Lieferant (Name, Anschrift)</Label>
|
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
|
||||||
<Input
|
>
|
||||||
id="treatment-supplier"
|
{({ id, describedBy }) => (
|
||||||
className="h-11"
|
<Input
|
||||||
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
|
id={id}
|
||||||
value={supplier}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setSupplier(event.target.value)}
|
type="number"
|
||||||
/>
|
inputMode="numeric"
|
||||||
</div>
|
min={0}
|
||||||
<div className="flex flex-col gap-2">
|
className="h-11"
|
||||||
<Label htmlFor="treatment-proof">Beleg für den Erwerb</Label>
|
value={values.withdrawalPeriodDays}
|
||||||
<Input
|
onChange={(event) =>
|
||||||
id="treatment-proof"
|
set("withdrawalPeriodDays", event.target.value)
|
||||||
className="h-11"
|
}
|
||||||
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
|
/>
|
||||||
value={purchaseProof}
|
)}
|
||||||
onChange={(event) => setPurchaseProof(event.target.value)}
|
</Field>
|
||||||
/>
|
</FormSection>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
<FormSection
|
||||||
<Label htmlFor="treatment-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
|
step={3}
|
||||||
<Input
|
title="Für das Bestandsbuch"
|
||||||
id="treatment-vet"
|
description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
|
||||||
className="h-11"
|
>
|
||||||
value={prescribingVet}
|
<Field label="Lieferant (Name, Anschrift)">
|
||||||
onChange={(event) => setPrescribingVet(event.target.value)}
|
{({ id }) => (
|
||||||
/>
|
<Input
|
||||||
</div>
|
id={id}
|
||||||
</fieldset>
|
className="h-11"
|
||||||
<div className="flex flex-col gap-2">
|
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
|
||||||
<Label htmlFor="treatment-notes">Notizen</Label>
|
value={values.supplier}
|
||||||
<Textarea
|
onChange={(event) => set("supplier", event.target.value)}
|
||||||
id="treatment-notes"
|
/>
|
||||||
value={notes}
|
)}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
</Field>
|
||||||
/>
|
<Field label="Beleg für den Erwerb">
|
||||||
</div>
|
{({ id }) => (
|
||||||
{error ? (
|
<Input
|
||||||
<p role="alert" className="text-sm text-destructive">
|
id={id}
|
||||||
{error}
|
className="h-11"
|
||||||
</p>
|
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
|
||||||
) : null}
|
value={values.purchaseProof}
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
onChange={(event) =>
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Behandlung speichern"}
|
set("purchaseProof", event.target.value)
|
||||||
</Button>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Verschreibender Tierarzt (falls zutreffend)">
|
||||||
|
{({ id }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
className="h-11"
|
||||||
|
value={values.prescribingVet}
|
||||||
|
onChange={(event) =>
|
||||||
|
set("prescribingVet", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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={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 gespeichert…"
|
||||||
|
: "Behandlung speichern"}
|
||||||
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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