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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 12:14:07 +02:00
1 parent 832997f885
commit 62e78498bf
18 files changed
+2201 -560

No files matched your search

@@ -0,0 +1,52 @@
import type { ApiaryInput } from "@/lib/apiaries/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ApiaryFormValues {
name: string;
location: string;
latitude: string;
longitude: string;
address: string;
notes: string;
}
export type ApiaryFormErrors = Partial<Record<keyof ApiaryFormValues, string>>;
export function initialApiaryValues(): ApiaryFormValues {
return {
name: "",
location: "",
latitude: "",
longitude: "",
address: "",
notes: "",
};
}
/** Required fields in form order, so the first error is the first field. */
export function validateApiary(values: ApiaryFormValues): ApiaryFormErrors {
const errors: ApiaryFormErrors = {};
if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben.";
return errors;
}
/** Six decimals are about 10 cm - more precise than any phone GPS. */
export function formatCoordinate(value: number): string {
return value.toFixed(6);
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
return {
name: values.name.trim(),
location: textOrUndefined(values.location),
latitude: numberOrUndefined(values.latitude),
longitude: numberOrUndefined(values.longitude),
address: textOrUndefined(values.address),
notes: textOrUndefined(values.notes),
};
}
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewApiaryPage from "./page";
import { createApiary } from "@/lib/apiaries/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/lib/apiaries/api", () => ({ createApiary: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewApiaryPage />
</QueryClientProvider>,
);
}
function mockGeolocation(
getCurrentPosition: Geolocation["getCurrentPosition"],
) {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { getCurrentPosition },
});
}
describe("NewApiaryPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(createApiary).mockReset();
});
afterEach(() => {
Reflect.deleteProperty(navigator, "geolocation");
});
it("marks only the name as required", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(1);
expect(
screen.getByRole("button", { name: /Notizen/ }),
).toHaveAttribute("aria-expanded", "false");
});
it("creates the apiary with the entered values", async () => {
vi.mocked(createApiary).mockResolvedValue({ id: "apiary-3" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Name/), "Hausgarten");
await userEvent.type(screen.getByLabelText(/Adresse/), "Dorfstraße 1");
await userEvent.click(
screen.getByRole("button", { name: "Bienenstand anlegen" }),
);
expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({
name: "Hausgarten",
location: undefined,
latitude: undefined,
longitude: undefined,
address: "Dorfstraße 1",
notes: undefined,
});
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
});
it("shows an inline error and does not save without a name", async () => {
renderPage();
await userEvent.click(
screen.getByRole("button", { name: "Bienenstand anlegen" }),
);
expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/Name/)).toHaveFocus();
expect(createApiary).not.toHaveBeenCalled();
});
it("fills latitude and longitude from the device location", async () => {
mockGeolocation((success) =>
success({
coords: { latitude: 52.5200081, longitude: 13.4049541 },
} as GeolocationPosition),
);
vi.mocked(createApiary).mockResolvedValue({ id: "apiary-4" } as never);
renderPage();
await userEvent.click(
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
);
expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(52.520008);
expect(screen.getByLabelText(/Längengrad/)).toHaveValue(13.404954);
await userEvent.type(screen.getByLabelText(/Name/), "Waldrand");
await userEvent.click(
screen.getByRole("button", { name: "Bienenstand anlegen" }),
);
expect(vi.mocked(createApiary).mock.calls[0][0]).toMatchObject({
latitude: 52.520008,
longitude: 13.404954,
});
});
it("explains inline when the location permission is denied", async () => {
mockGeolocation((_success, failure) =>
failure?.({ code: 1 } as GeolocationPositionError),
);
renderPage();
await userEvent.click(
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
);
expect(screen.getByRole("status")).toHaveTextContent(
/Standort-Freigabe abgelehnt/,
);
expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(null);
});
it("explains inline when the device has no location service", async () => {
renderPage();
await userEvent.click(
screen.getByRole("button", { name: /Meinen Standort übernehmen/ }),
);
expect(screen.getByRole("status")).toHaveTextContent(/nicht verfügbar/);
});
});
@@ -2,49 +2,120 @@
import { FormEvent, useState } from "react"; import { 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}
noValidate
>
<FormSection step={1} title="Wie heißt der Stand?">
<Field label="Name" required error={errors.name}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="apiary-name" id={id}
data-field="name"
required required
value={name} aria-describedby={describedBy}
onChange={(event) => setName(event.target.value)} aria-invalid={invalid}
value={values.name}
onChange={(event) => set("name", event.target.value)}
/> />
</div> )}
</Field>
<Field label="Standort" hint="Kurze Beschreibung, z. B. „Hinter der Scheune“">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
value={values.location}
onChange={(event) => set("location", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={2}
title="Wo genau?"
description="Am Stand selbst einfach den Standort übernehmen."
>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="apiary-location">Standort</Label> <Button
<Input type="button"
id="apiary-location" size="touch"
value={location} variant="outline"
onChange={(event) => setLocation(event.target.value)} onClick={takeMyLocation}
/> disabled={locating}
</div> >
<div className="flex gap-3"> <LocateFixed aria-hidden />
<div className="flex flex-1 flex-col gap-2"> {locating ? "Standort wird ermittelt…" : "Meinen Standort übernehmen"}
<Label htmlFor="apiary-latitude">Breitengrad</Label> </Button>
<Input {locateMessage ? (
id="apiary-latitude" <p role="status" className="rounded-lg bg-warning/15 p-3 text-sm">
type="number" {locateMessage}
step="0.000001"
inputMode="decimal"
placeholder="z. B. 52.5200"
value={latitude}
onChange={(event) => setLatitude(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-longitude">Längengrad</Label>
<Input
id="apiary-longitude"
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 13.4050"
value={longitude}
onChange={(event) => setLongitude(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-address">Adresse</Label>
<Input
id="apiary-address"
value={address}
onChange={(event) => setAddress(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-notes">Notizen</Label>
<Textarea
id="apiary-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p> </p>
) : null} ) : null}
<Button type="submit" disabled={mutation.isPending}> </div>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"} <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Breitengrad" hint="z. B. 52.520008">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
step="0.000001"
min={-90}
max={90}
value={values.latitude}
onChange={(event) => set("latitude", event.target.value)}
/>
)}
</Field>
<Field label="Längengrad" hint="z. B. 13.404954">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
step="0.000001"
min={-180}
max={180}
value={values.longitude}
onChange={(event) => set("longitude", event.target.value)}
/>
)}
</Field>
</div>
<Field label="Adresse">
{({ id }) => (
<Input
id={id}
autoComplete="street-address"
value={values.address}
onChange={(event) => set("address", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Notizen"
collapsible
defaultOpen={false}
fieldCount={1}
>
<Field label="Notizen">
{({ id }) => (
<Textarea
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Bienenstand anlegen"}
</Button> </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,91 +22,179 @@ 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"
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="code"
required
autoComplete="off"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.code}
onChange={(event) => set("code", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="colony-apiary">Bienenstand</Label> <Field
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}> label="Bienenstand"
<SelectTrigger id="colony-apiary"> required
<SelectValue placeholder="Bienenstand wählen" /> error={errors.apiaryId}
hint={
!apiariesLoading && activeApiaries.length === 0
? "Noch kein aktiver Bienenstand – zuerst unter „Bienenstände“ einen anlegen."
: undefined
}
>
{({ id, describedBy, invalid }) => (
<Select
value={values.apiaryId}
onValueChange={(value) => set("apiaryId", value ?? "")}
>
<SelectTrigger
id={id}
data-field="apiaryId"
className="w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
apiariesLoading
? "Bienenstände werden geladen…"
: "Bienenstand wählen"
}
>
{(value: string) =>
activeApiaries.find((apiary) => apiary.id === value)
?.name ?? null
}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(apiaries ?? []).map((apiary) => ( {activeApiaries.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}> <SelectItem key={apiary.id} value={apiary.id}>
{apiary.name} {apiary.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="colony-type">Volkstyp</Label> <Field label="Volkstyp">
{({ id }) => (
<Select <Select
value={colonyType} value={values.colonyType}
onValueChange={(value) => setColonyType(value as ColonyType)} onValueChange={(value) =>
set("colonyType", value as ColonyType)
}
> >
<SelectTrigger id="colony-type"> <SelectTrigger id={id} className="w-full">
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue> <SelectValue>
{COLONY_TYPE_LABELS[values.colonyType]}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{COLONY_TYPES.map((type) => ( {COLONY_TYPES.map((type) => (
@@ -114,31 +204,59 @@ export default function NewColonyPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="colony-hive-type">Beutentyp</Label> </FormSection>
<FormSection
step={2}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
>
<Field label="Beutentyp" hint="z. B. Zander, Dadant">
{({ id, describedBy }) => (
<Input <Input
id="colony-hive-type" id={id}
value={hiveType} aria-describedby={describedBy}
onChange={(event) => setHiveType(event.target.value)} value={values.hiveType}
onChange={(event) => set("hiveType", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="colony-origin">Herkunft</Label> <Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
{({ id, describedBy }) => (
<Input <Input
id="colony-origin" id={id}
value={originText} aria-describedby={describedBy}
onChange={(event) => setOriginText(event.target.value)} value={values.originText}
onChange={(event) => set("originText", event.target.value)}
/> />
</div> )}
{error ? ( </Field>
<p role="alert" className="text-sm text-destructive"> </FormSection>
{error}
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p> </p>
) : null} ) : null
<Button type="submit" disabled={mutation.isPending}> }
{mutation.isPending ? "Wird angelegt…" : "Anlegen"} >
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Volk anlegen"}
</Button> </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">
<Label htmlFor="equipment-name">Bezeichnung</Label>
<Input <Input
id="equipment-name" id={id}
data-field="name"
required required
value={name} aria-describedby={describedBy}
onChange={(event) => setName(event.target.value)} aria-invalid={invalid}
value={values.name}
onChange={(event) => set("name", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="equipment-manufacturer">Hersteller</Label> <Field
label="Inventarnummer"
required
error={errors.inventoryNumber}
hint="Deine eigene Nummer, z. B. vom Aufkleber am Gerät"
>
{({ id, describedBy, invalid }) => (
<Input <Input
id="equipment-manufacturer" id={id}
value={manufacturer} data-field="inventoryNumber"
onChange={(event) => setManufacturer(event.target.value)} required
autoComplete="off"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.inventoryNumber}
onChange={(event) =>
set("inventoryNumber", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="equipment-purchase-date">Kaufdatum</Label> <Field label="Standort" hint="Wo steht es, z. B. „Keller“">
{({ id, describedBy }) => (
<Input <Input
id="equipment-purchase-date" id={id}
aria-describedby={describedBy}
value={values.location}
onChange={(event) => set("location", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={2}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={3}
>
<Field label="Hersteller">
{({ id }) => (
<Input
id={id}
value={values.manufacturer}
onChange={(event) => set("manufacturer", event.target.value)}
/>
)}
</Field>
<Field label="Kaufdatum">
{({ id }) => (
<Input
id={id}
type="date" type="date"
value={purchaseDate} value={values.purchaseDate}
onChange={(event) => setPurchaseDate(event.target.value)} onChange={(event) => set("purchaseDate", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="equipment-location">Standort</Label> <Field label="Wartungsdatum">
{({ id }) => (
<Input <Input
id="equipment-location" id={id}
value={location}
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-maintenance-date">Wartungsdatum</Label>
<Input
id="equipment-maintenance-date"
type="date" type="date"
value={maintenanceDate} value={values.maintenanceDate}
onChange={(event) => setMaintenanceDate(event.target.value)} onChange={(event) =>
set("maintenanceDate", event.target.value)
}
/> />
</div> )}
{error ? ( </Field>
<p role="alert" className="text-sm text-destructive"> </FormSection>
{error}
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p> </p>
) : null} ) : null
<Button type="submit" disabled={mutation.isPending}> }
{mutation.isPending ? "Wird angelegt…" : "Anlegen"} >
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Gegenstand anlegen"}
</Button> </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,16 +204,52 @@ 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?">
<Field
label="Volk"
required
error={fieldErrors.colonyId}
hint={
!coloniesLoading && activeColonies.length === 0
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
value={values.colonyId}
onValueChange={(value) => set("colonyId", value ?? "")}
>
<SelectTrigger
id="feeding-colony"
data-field="colonyId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{activeColonies.map((colony) => ( {activeColonies.map((colony) => (
@@ -126,95 +259,132 @@ export default function NewFeedingPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-date">Datum</Label> <Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="feeding-date" id={id}
data-field="date"
type="date" type="date"
required required
className="h-11" className="h-11"
value={date} aria-describedby={describedBy}
onChange={(event) => setDate(event.target.value)} aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-type">Futterart</Label> <Field label="Futterart" required>
<Select {({ id, labelId }) => (
value={feedType} <ChoiceGroup
onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)} id={id}
> labelledBy={labelId}
<SelectTrigger id="feeding-type" className="h-11 w-full"> options={FEED_TYPE_OPTIONS}
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue> value={values.feedType}
</SelectTrigger> onChange={(feedType) => set("feedType", feedType)}
<SelectContent> className="grid-cols-2 sm:grid-cols-3"
{FEED_TYPES.map((option) => ( />
<SelectItem key={option} value={option}> )}
{FEED_TYPE_LABELS[option]} </Field>
</SelectItem> <Field label="Menge" required error={fieldErrors.amount}>
))} {({ id, describedBy, invalid }) => (
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="feeding-amount">Menge</Label>
<Input <Input
id="feeding-amount" id={id}
data-field="amount"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
step="any"
required required
className="h-11" className="h-11"
value={amount} aria-describedby={describedBy}
onChange={(event) => setAmount(event.target.value)} aria-invalid={invalid}
value={values.amount}
onChange={(event) => set("amount", event.target.value)}
/> />
</div> )}
<div className="flex flex-1 flex-col gap-2"> </Field>
<Label htmlFor="feeding-unit">Einheit</Label> <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 <Input
id="feeding-unit" id={id}
required aria-describedby={describedBy}
className="h-11" className="h-11"
value={unit} value={values.product}
onChange={(event) => setUnit(event.target.value)} onChange={(event) => set("product", event.target.value)}
/> />
</div> )}
</div> </Field>
<div className="flex flex-col gap-2"> <Field
<Label htmlFor="feeding-product">Produkt</Label> label="Chargennummer"
hint="Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."
>
{({ id, describedBy }) => (
<Input <Input
id="feeding-product" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={product} value={values.batchNumber}
onChange={(event) => setProduct(event.target.value)} onChange={(event) => set("batchNumber", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-batch">Chargennummer</Label> <Field label="Notizen">
<Input {({ id }) => (
id="feeding-batch"
className="h-11"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-notes">Notizen</Label>
<Textarea <Textarea
id="feeding-notes" id={id}
value={notes} value={values.notes}
onChange={(event) => setNotes(event.target.value)} onChange={(event) => set("notes", event.target.value)}
/> />
</div> )}
{error ? ( </Field>
<p role="alert" className="text-sm text-destructive"> </FormSection>
{error}
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p> </p>
) : null} ) : null
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}> }
{mutation.isPending ? "Wird gespeichert…" : "Fütterung speichern"} >
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending
? "Wird gespeichert…"
: "Fütterung speichern"}
</Button> </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“"
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="name"
required
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.name}
onChange={(event) => set("name", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="product-description">Beschreibung</Label> <Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
value={values.salesUnit}
onChange={(event) => set("salesUnit", event.target.value)}
/>
)}
</Field>
<Field
label="Gebindegröße (g)"
hint="Füllmenge in Gramm, z. B. 500"
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
step="0.01"
min={0}
value={values.packagingSize}
onChange={(event) =>
set("packagingSize", event.target.value)
}
/>
)}
</Field>
</FormSection>
<FormSection
step={2}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={3}
>
<Field
label="Gewicht (g)"
hint="in Gramm"
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
step="0.01"
min={0}
value={values.weight}
onChange={(event) => set("weight", event.target.value)}
/>
)}
</Field>
<Field label="SKU" hint="Artikelnummer für Shop/Lager">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
autoComplete="off"
value={values.sku}
onChange={(event) => set("sku", event.target.value)}
/>
)}
</Field>
<Field label="Beschreibung">
{({ id }) => (
<Textarea <Textarea
id="product-description" id={id}
value={description} value={values.description}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => set("description", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="product-sku">SKU</Label> </FormSection>
<Input
id="product-sku" <StickySubmit
value={sku} note={
onChange={(event) => setSku(event.target.value)} submitError ? (
/> <p
</div> role="alert"
<div className="flex flex-col gap-2"> className="text-sm font-medium text-destructive"
<Label htmlFor="product-packaging-size">Gebindegröße</Label> >
<Input {submitError}
id="product-packaging-size"
type="number"
step="0.01"
value={packagingSize}
onChange={(event) => setPackagingSize(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-weight">Gewicht</Label>
<Input
id="product-weight"
type="number"
step="0.01"
value={weight}
onChange={(event) => setWeight(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-sales-unit">Verkaufseinheit</Label>
<Input
id="product-sales-unit"
placeholder="z. B. Glas, Flasche"
value={salesUnit}
onChange={(event) => setSalesUnit(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p> </p>
) : null} ) : null
<Button type="submit" disabled={mutation.isPending}> }
{mutation.isPending ? "Wird angelegt…" : "Anlegen"} >
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Produkt anlegen"}
</Button> </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,17 +140,53 @@ 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?">
<Field
label="Volk"
required
error={fieldErrors.colonyId}
hint={
!coloniesLoading && activeColonies.length === 0
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
value={values.colonyId}
onValueChange={(value) => set("colonyId", value ?? "")}
>
<SelectTrigger
id="treatment-colony"
data-field="colonyId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{activeColonies.map((colony) => ( {activeColonies.map((colony) => (
@@ -132,161 +196,237 @@ export default function NewTreatmentPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-date">Datum</Label> <Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="treatment-date" id={id}
data-field="date"
type="date" type="date"
required required
className="h-11" className="h-11"
value={date} aria-describedby={describedBy}
onChange={(event) => setDate(event.target.value)} aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-start-date">Beginn</Label> <Field
label="Mittel"
required
error={fieldErrors.product}
hint="Handelsname, z. B. „Oxuvar“"
>
{({ id, describedBy, invalid }) => (
<Input <Input
id="treatment-start-date" id={id}
type="date" data-field="product"
className="h-11"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-end-date">Ende</Label>
<Input
id="treatment-end-date"
type="date"
className="h-11"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-product">Mittel</Label>
<Input
id="treatment-product"
required required
className="h-11" className="h-11"
value={product} aria-describedby={describedBy}
onChange={(event) => setProduct(event.target.value)} aria-invalid={invalid}
value={values.product}
onChange={(event) => set("product", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-active-ingredient">Wirkstoff</Label> </FormSection>
<FormSection step={2} title="Mittel & Anwendung">
<Field
label="Wirkstoff"
hint="Steht auf der Packung, z. B. Ameisensäure."
>
{({ id, describedBy }) => (
<Input <Input
id="treatment-active-ingredient" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={activeIngredient} value={values.activeIngredient}
onChange={(event) => setActiveIngredient(event.target.value)} onChange={(event) =>
set("activeIngredient", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-amount">Menge</Label> <Field label="Menge" hint="z. B. „50 ml“">
{({ id, describedBy }) => (
<Input <Input
id="treatment-amount" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={amount} value={values.amount}
onChange={(event) => setAmount(event.target.value)} onChange={(event) => set("amount", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-method">Methode</Label> <Field label="Methode" hint="z. B. Träufeln, Verdunsten">
{({ id, describedBy }) => (
<Input <Input
id="treatment-method" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={method} value={values.method}
onChange={(event) => setMethod(event.target.value)} onChange={(event) => set("method", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-reason">Grund</Label> <Field
label="Chargennummer"
hint="Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."
>
{({ id, describedBy }) => (
<Input <Input
id="treatment-reason" id={id}
aria-describedby={describedBy}
className="h-11" className="h-11"
value={reason} value={values.batchNumber}
onChange={(event) => setReason(event.target.value)} onChange={(event) => set("batchNumber", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-batch">Chargennummer</Label> <Field
label="Wartezeit (Tage)"
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
>
{({ id, describedBy }) => (
<Input <Input
id="treatment-batch" id={id}
className="h-11" aria-describedby={describedBy}
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-withdrawal">Wartezeit (Tage)</Label>
<Input
id="treatment-withdrawal"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
className="h-11" className="h-11"
value={withdrawalPeriodDays} value={values.withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)} onChange={(event) =>
set("withdrawalPeriodDays", event.target.value)
}
/> />
<p className="text-xs text-muted-foreground"> )}
Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig </Field>
geerntet werden. </FormSection>
</p>
</div> <FormSection
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3"> step={3}
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend> title="Für das Bestandsbuch"
<p className="text-xs text-muted-foreground"> description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 >
VO (EU) 2019/6). Belege 5 Jahre aufbewahren. <Field label="Lieferant (Name, Anschrift)">
</p> {({ id }) => (
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-supplier">Lieferant (Name, Anschrift)</Label>
<Input <Input
id="treatment-supplier" id={id}
className="h-11" className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster" placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier} value={values.supplier}
onChange={(event) => setSupplier(event.target.value)} onChange={(event) => set("supplier", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-proof">Beleg für den Erwerb</Label> <Field label="Beleg für den Erwerb">
{({ id }) => (
<Input <Input
id="treatment-proof" id={id}
className="h-11" className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026" placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof} value={values.purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)} onChange={(event) =>
set("purchaseProof", event.target.value)
}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-vet">Verschreibender Tierarzt (falls zutreffend)</Label> <Field label="Verschreibender Tierarzt (falls zutreffend)">
{({ id }) => (
<Input <Input
id="treatment-vet" id={id}
className="h-11" className="h-11"
value={prescribingVet} value={values.prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)} onChange={(event) =>
set("prescribingVet", event.target.value)
}
/> />
</div> )}
</fieldset> </Field>
<div className="flex flex-col gap-2"> </FormSection>
<Label htmlFor="treatment-notes">Notizen</Label>
<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 <Textarea
id="treatment-notes" id={id}
value={notes} value={values.notes}
onChange={(event) => setNotes(event.target.value)} onChange={(event) => set("notes", event.target.value)}
/> />
</div> )}
{error ? ( </Field>
<p role="alert" className="text-sm text-destructive"> </FormSection>
{error}
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p> </p>
) : null} ) : null
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}> }
{mutation.isPending ? "Wird gespeichert…" : "Behandlung speichern"} >
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending
? "Wird gespeichert…"
: "Behandlung speichern"}
</Button> </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),
};
}