feat(ui): remaining capture forms with sections, tap choices and drafts

Varroa, swarm control, harvest, extraction, bottling, task and queen
forms use the form kit: required fields marked, inline errors with focus
on the first problem, rare fields folded, drafts, save in thumb reach.
Single choices (varroa method, swarm mood/action, priority, marking
colour) are large tap buttons; colonies and harvests are picked as
tiles. Plain hints: per-day mite fall vs. threshold, Brutto/Netto,
water content, MHD and lot number, marking colour by year.

Fixed on the way: the queen form suggested the marking colour from the
first digit typed ("2" -> Gelb for 2026); it now waits for a full year.

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:25:24 +02:00
1 parent 49f3db5b56
commit ed0a000534
21 files changed
+2631 -660

No files matched your search

@@ -0,0 +1,42 @@
import type { createBottlingBatch } from "@/lib/bottling/api";
/** Everything the form holds, as plain values that survive a draft save. */
export interface BottlingFormValues {
honeyBatchId: string;
productId: string;
date: string;
jarSize: string;
jarCount: string;
bestBeforeDate: string;
lotNumber: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialBottlingValues(honeyBatchId: string): BottlingFormValues {
return {
honeyBatchId,
productId: "",
date: todayIsoDate(),
jarSize: "500",
jarCount: "",
bestBeforeDate: "",
lotNumber: "",
};
}
export function toBottlingInput(
values: BottlingFormValues,
): Parameters<typeof createBottlingBatch>[0] {
return {
honeyBatchId: values.honeyBatchId,
productId: values.productId || undefined,
date: new Date(values.date).toISOString(),
jarSize: Number(values.jarSize),
jarCount: Number(values.jarCount),
bestBeforeDate: new Date(values.bestBeforeDate).toISOString(),
lotNumber: values.lotNumber.trim() ? values.lotNumber.trim() : undefined,
};
}
@@ -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 NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"),
}));
vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewBottlingBatchPage />
</QueryClientProvider>,
);
}
describe("NewBottlingBatchPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchHoneyBatches).mockResolvedValue([
{ id: "hb-1", batchNumber: "2026-01", amount: 40 },
] as never);
vi.mocked(fetchProducts).mockResolvedValue([] as never);
vi.mocked(createBottlingBatch).mockReset();
});
it("marks the five required fields and explains the units", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(5);
expect(screen.getByText(/in Gramm/)).toBeInTheDocument();
expect(screen.getByText(/in Stück/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Los-\/Chargennummer/)).not.toBeInTheDocument();
});
it("sends the bottling with jar size, count and best-before date", async () => {
vi.mocked(createBottlingBatch).mockResolvedValue({ id: "b-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(
screen.getByLabelText(/Mindesthaltbarkeitsdatum/),
"2028-07-01",
);
await userEvent.click(
screen.getByRole("button", { name: "Abfüllung anlegen" }),
);
const input = vi.mocked(createBottlingBatch).mock.calls[0][0];
expect(input).toMatchObject({
honeyBatchId: "hb-1",
jarSize: 500,
jarCount: 24,
bestBeforeDate: new Date("2028-07-01").toISOString(),
});
expect(input.productId).toBeUndefined();
expect(push).toHaveBeenCalledWith("/bottling/b-1");
});
it("shows an inline error and focuses the jar count when it is missing", async () => {
renderPage();
await userEvent.type(
screen.getByLabelText(/Mindesthaltbarkeitsdatum/),
"2028-07-01",
);
await userEvent.click(
screen.getByRole("button", { name: "Abfüllung anlegen" }),
);
expect(
screen.getByText("Bitte die Anzahl Gläser angeben."),
).toBeInTheDocument();
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveFocus();
expect(createBottlingBatch).not.toHaveBeenCalled();
});
});
@@ -3,13 +3,15 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { Package } from "lucide-react";
import { createBottlingBatch } from "@/lib/bottling/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -19,22 +21,68 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
BottlingFormValues,
initialBottlingValues,
toBottlingInput,
} from "./bottling-form-values";
type RequiredKey =
| "honeyBatchId"
| "date"
| "jarSize"
| "jarCount"
| "bestBeforeDate";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = [
"honeyBatchId",
"date",
"jarSize",
"jarCount",
"bestBeforeDate",
];
function isPositiveNumber(value: string): boolean {
return value.trim() !== "" && Number(value) > 0;
}
function validate(values: BottlingFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.honeyBatchId) {
errors.honeyBatchId = "Bitte eine Honigcharge auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (!isPositiveNumber(values.jarSize)) {
errors.jarSize = "Bitte die Glasgröße in Gramm angeben.";
}
if (!isPositiveNumber(values.jarCount)) {
errors.jarCount = "Bitte die Anzahl Gläser angeben.";
}
if (!values.bestBeforeDate) {
errors.bestBeforeDate = "Bitte das Mindesthaltbarkeitsdatum angeben.";
}
return errors;
}
export default function NewBottlingBatchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [honeyBatchId, setHoneyBatchId] = useState(searchParams.get("honeyBatchId") ?? "");
const [productId, setProductId] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [jarSize, setJarSize] = useState("500");
const [jarCount, setJarCount] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [error, setError] = useState<string | null>(null);
const [values, setValues] = useState<BottlingFormValues>(() =>
initialBottlingValues(searchParams.get("honeyBatchId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("bottling-new", values, dirty);
const { data: honeyBatches } = useQuery({
const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({
queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches(),
});
@@ -43,30 +91,54 @@ export default function NewBottlingBatchPage() {
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
function set<K extends keyof BottlingFormValues>(
key: K,
value: BottlingFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
const mutation = useMutation({
mutationFn: createBottlingBatch,
onSuccess: (bottlingBatch) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success("Abfüllung angelegt");
router.push(`/bottling/${bottlingBatch.id}`);
},
onError: (mutationError: Error) => {
setError(mutationError.message || "Abfüllung konnte nicht angelegt werden.");
setSubmitError(
errorMessage(mutationError, "Abfüllung konnte nicht angelegt werden."),
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
honeyBatchId,
productId: productId || undefined,
date: new Date(date).toISOString(),
jarSize: Number(jarSize),
jarCount: Number(jarCount),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
lotNumber: lotNumber || undefined,
});
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate(toBottlingInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
return (
@@ -76,35 +148,164 @@ export default function NewBottlingBatchPage() {
title="Neue Abfüllung"
description="Fülle eine Honigcharge in Gläser ab."
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-honey-batch">Honigcharge</Label>
<Select
value={honeyBatchId}
onValueChange={(value) => setHoneyBatchId(value ?? "")}
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<SelectTrigger id="bottling-honey-batch">
<SelectValue placeholder="Charge wählen" />
<FormSection step={1} title="Welcher Honig, wann?">
<Field
label="Honigcharge"
required
error={fieldErrors.honeyBatchId}
hint={
!honeyBatchesLoading && honeyBatches?.length === 0
? "Noch keine Honigchargen – zuerst eine Schleuderung erfassen."
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
value={values.honeyBatchId}
onValueChange={(value) => set("honeyBatchId", value ?? "")}
>
<SelectTrigger
id="bottling-honey-batch"
data-field="honeyBatchId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
honeyBatchesLoading
? "Chargen werden geladen…"
: "Charge wählen"
}
/>
</SelectTrigger>
<SelectContent>
{honeyBatches?.map((honeyBatch) => (
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
{honeyBatch.batchNumber}
{honeyBatch.amount !== null ? ` · ${honeyBatch.amount} kg` : ""}
{honeyBatch.amount !== null
? ` · ${honeyBatch.amount} kg`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-product">Produkt (optional)</Label>
<Select
value={productId}
onValueChange={(value) => setProductId(value ?? "")}
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="date"
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection step={2} title="Gläser">
<Field
label="Glasgröße (g)"
required
error={fieldErrors.jarSize}
hint="Honig pro Glas in Gramm, z. B. 500 oder 250."
>
<SelectTrigger id="bottling-product">
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="jarSize"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.jarSize}
onChange={(event) => set("jarSize", event.target.value)}
/>
)}
</Field>
<Field
label="Anzahl Gläser"
required
error={fieldErrors.jarCount}
hint="Volle Gläser in Stück."
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="jarCount"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.jarCount}
onChange={(event) => set("jarCount", event.target.value)}
/>
)}
</Field>
<Field
label="Mindesthaltbarkeitsdatum"
required
error={fieldErrors.bestBeforeDate}
hint="Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen."
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="bestBeforeDate"
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.bestBeforeDate}
onChange={(event) =>
set("bestBeforeDate", event.target.value)
}
/>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
>
<Field label="Produkt">
{({ id }) => (
<Select
value={values.productId}
onValueChange={(value) => set("productId", value ?? "")}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
@@ -115,64 +316,45 @@ export default function NewBottlingBatchPage() {
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-date">Datum</Label>
)}
</Field>
<Field
label="Los-/Chargennummer"
hint="Steht aufs Etikett, damit jedes Glas seiner Charge zugeordnet werden kann."
>
{({ id, describedBy }) => (
<Input
id="bottling-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-jar-size">Glasgröße (g)</Label>
<Input
id="bottling-jar-size"
type="number"
required
value={jarSize}
onChange={(event) => setJarSize(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-jar-count">Anzahl Gläser</Label>
<Input
id="bottling-jar-count"
type="number"
required
value={jarCount}
onChange={(event) => setJarCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-best-before">Mindesthaltbarkeitsdatum</Label>
<Input
id="bottling-best-before"
type="date"
required
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-lot-number">Los-/Chargennummer</Label>
<Input
id="bottling-lot-number"
id={id}
aria-describedby={describedBy}
className="h-11"
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
value={values.lotNumber}
onChange={(event) => set("lotNumber", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending || !honeyBatchId}>
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Abfüllung anlegen"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,46 @@
import type { createExtraction } from "@/lib/extractions/api";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ExtractionFormValues {
date: string;
harvestIds: string[];
amount: string;
room: string;
waterContent: string;
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialExtractionValues(
preselectedHarvestId: string | null,
): ExtractionFormValues {
return {
date: todayIsoDate(),
harvestIds: preselectedHarvestId ? [preselectedHarvestId] : [],
amount: "",
room: "",
waterContent: "",
notes: "",
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toExtractionInput(
values: ExtractionFormValues,
): Parameters<typeof createExtraction>[0] {
return {
date: new Date(values.date).toISOString(),
harvestIds: values.harvestIds,
amount: numberOrUndefined(values.amount),
room: textOrUndefined(values.room),
waterContent: numberOrUndefined(values.waterContent),
notes: textOrUndefined(values.notes),
};
}
@@ -0,0 +1,83 @@
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 NewExtractionPage from "./page";
import { createExtraction } from "@/lib/extractions/api";
import { fetchHarvests } from "@/lib/harvests/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/lib/harvests/api", () => ({ fetchHarvests: vi.fn() }));
vi.mock("@/lib/extractions/api", () => ({ createExtraction: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewExtractionPage />
</QueryClientProvider>,
);
}
describe("NewExtractionPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchHarvests).mockResolvedValue([
{
id: "harvest-1",
date: "2026-07-01T00:00:00.000Z",
colonies: [{ colony: { code: "V-01" } }],
},
] as never);
vi.mocked(createExtraction).mockReset();
});
it("marks date and harvests as required and keeps the room folded", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
expect(screen.queryByLabelText(/Schleuderraum/)).not.toBeInTheDocument();
expect(screen.getByText(/Refraktometer/)).toBeInTheDocument();
});
it("sends the extraction with the tapped harvest", async () => {
vi.mocked(createExtraction).mockResolvedValue({ id: "e-1" } as never);
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
await userEvent.type(screen.getByLabelText(/Menge/), "18.5");
await userEvent.type(screen.getByLabelText(/Wassergehalt/), "17");
await userEvent.click(
screen.getByRole("button", { name: "Schleuderung speichern" }),
);
const input = vi.mocked(createExtraction).mock.calls[0][0];
expect(input).toMatchObject({
harvestIds: ["harvest-1"],
amount: 18.5,
waterContent: 17,
});
expect(input.room).toBeUndefined();
expect(push).toHaveBeenCalledWith("/extractions/e-1");
});
it("shows an inline error and focuses the harvests when none is chosen", async () => {
renderPage();
await screen.findByRole("checkbox", { name: /V-01/ });
await userEvent.click(
screen.getByRole("button", { name: "Schleuderung speichern" }),
);
expect(
screen.getByText("Bitte mindestens eine Ernte auswählen."),
).toBeInTheDocument();
expect(screen.getByRole("group", { name: /^Ernten/ })).toHaveFocus();
expect(createExtraction).not.toHaveBeenCalled();
});
});
@@ -7,15 +7,36 @@ import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
ExtractionFormValues,
initialExtractionValues,
toExtractionInput,
} from "./extraction-form-values";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
type RequiredKey = "date" | "harvestIds";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["date", "harvestIds"];
function validate(values: ExtractionFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (values.harvestIds.length === 0) {
errors.harvestIds = "Bitte mindestens eine Ernte auswählen.";
}
return errors;
}
export default function NewExtractionPage() {
@@ -23,52 +44,78 @@ export default function NewExtractionPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: harvests } = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
const { data: harvests } = useQuery({
queryKey: ["harvests"],
queryFn: fetchHarvests,
});
const preselectedHarvestId = searchParams.get("harvestId");
const [date, setDate] = useState(todayIsoDate());
const [harvestIds, setHarvestIds] = useState<string[]>(
preselectedHarvestId ? [preselectedHarvestId] : [],
const [values, setValues] = useState<ExtractionFormValues>(() =>
initialExtractionValues(searchParams.get("harvestId")),
);
const [amount, setAmount] = useState("");
const [room, setRoom] = useState("");
const [waterContent, setWaterContent] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("extraction-new", values, dirty);
function set<K extends keyof ExtractionFormValues>(
key: K,
value: ExtractionFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
function toggleHarvest(harvestId: string, checked: boolean) {
set(
"harvestIds",
checked
? [...values.harvestIds, harvestId]
: values.harvestIds.filter((id) => id !== harvestId),
);
}
const mutation = useMutation({
mutationFn: createExtraction,
onSuccess: (extraction) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
toast.success("Schleuderung gespeichert");
router.push(`/extractions/${extraction.id}`);
},
onError: () => {
setError("Schleuderung konnte nicht gespeichert werden.");
onError: (mutationError) => {
setSubmitError(
errorMessage(
mutationError,
"Schleuderung konnte nicht gespeichert werden.",
),
);
},
});
function toggleHarvest(harvestId: string, checked: boolean) {
setHarvestIds((current) =>
checked ? [...current, harvestId] : current.filter((id) => id !== harvestId),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (harvestIds.length === 0) {
setError("Bitte mindestens eine Ernte auswählen.");
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate({
date: new Date(date).toISOString(),
harvestIds,
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
notes: notes || undefined,
});
mutation.mutate(toExtractionInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
return (
@@ -76,97 +123,164 @@ export default function NewExtractionPage() {
<PageHeader
icon={Droplet}
title="Neue Schleuderung"
description="Erfasse eine neue Schleuderung aus einer oder mehreren Ernten."
description="Pflicht sind Datum und mindestens eine Ernte."
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-date">Datum</Label>
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<FormSection step={1} title="Wann, aus welchen Ernten?">
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
id="extraction-date"
id={id}
data-field="date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Ernten</Label>
{harvests?.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Ernten vorhanden.</p>
) : null}
<div className="flex flex-col gap-2">
{harvests?.map((harvest) => (
<label
key={harvest.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`extraction-harvest-${harvest.id}`}
)}
</Field>
<Field
label="Ernten"
required
error={fieldErrors.harvestIds}
hint={
harvests?.length === 0
? "Noch keine Ernten vorhanden – zuerst eine Ernte erfassen."
: "Alle Ernten antippen, deren Waben du geschleudert hast."
}
>
<input
id={`extraction-harvest-${harvest.id}`}
type="checkbox"
className="size-5"
checked={harvestIds.includes(harvest.id)}
onChange={(event) => toggleHarvest(harvest.id, event.target.checked)}
{({ id, labelId, describedBy }) => (
<div
id={id}
role="group"
tabIndex={-1}
data-field="harvestIds"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="flex flex-col gap-2 outline-none"
>
{harvests?.map((harvest) => (
<ToggleChip
key={harvest.id}
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
.map((entry) => entry.colony.code)
.join(", ")}`}
checked={values.harvestIds.includes(harvest.id)}
onChange={(checked) =>
toggleHarvest(harvest.id, checked)
}
/>
{new Date(harvest.date).toLocaleDateString("de-DE")} ·{" "}
{harvest.colonies.map((entry) => entry.colony.code).join(", ")}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-amount">Menge (kg)</Label>
)}
</Field>
</FormSection>
<FormSection step={2} title="Ergebnis">
<Field label="Menge (kg)" hint="Geschleuderter Honig, ohne Eimer.">
{({ id, describedBy }) => (
<Input
id="extraction-amount"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
step="any"
className="h-11"
value={amount}
onChange={(event) => setAmount(event.target.value)}
value={values.amount}
onChange={(event) => set("amount", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-room">Schleuderraum</Label>
)}
</Field>
<Field
label="Wassergehalt (%)"
hint="Mit dem Refraktometer gemessen. Über 18 % kann der Honig gären."
>
{({ id, describedBy }) => (
<Input
id="extraction-room"
className="h-11"
value={room}
onChange={(event) => setRoom(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-water">Wassergehalt (%)</Label>
<Input
id="extraction-water"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
max={100}
step="any"
className="h-11"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
value={values.waterContent}
onChange={(event) =>
set("waterContent", event.target.value)
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-notes">Notizen</Label>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
>
<Field label="Schleuderraum">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.room}
onChange={(event) => set("room", event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea
id="extraction-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Schleuderung speichern"}
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending
? "Wird gespeichert…"
: "Schleuderung speichern"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,50 @@
import type { createHarvest } from "@/lib/harvests/api";
/** Everything the form holds, as plain values that survive a draft save. */
export interface HarvestFormValues {
apiaryId: string;
date: string;
colonyIds: string[];
honeyType: string;
honeySuperCount: string;
grossWeight: string;
netWeight: string;
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialHarvestValues(apiaryId: string): HarvestFormValues {
return {
apiaryId,
date: todayIsoDate(),
colonyIds: [],
honeyType: "",
honeySuperCount: "",
grossWeight: "",
netWeight: "",
notes: "",
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toHarvestInput(
values: HarvestFormValues,
): Parameters<typeof createHarvest>[0] {
return {
apiaryId: values.apiaryId,
date: new Date(values.date).toISOString(),
colonyIds: values.colonyIds,
honeyType: textOrUndefined(values.honeyType),
honeySuperCount: numberOrUndefined(values.honeySuperCount),
grossWeight: numberOrUndefined(values.grossWeight),
netWeight: numberOrUndefined(values.netWeight),
notes: textOrUndefined(values.notes),
};
}
@@ -0,0 +1,93 @@
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 NewHarvestPage from "./page";
import { createHarvest } from "@/lib/harvests/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("apiaryId=apiary-1"),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueHarvest: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewHarvestPage />
</QueryClientProvider>,
);
}
describe("NewHarvestPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Garten" },
] as never);
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
vi.mocked(createHarvest).mockReset();
});
it("marks apiary, date and colonies as required and explains gross vs net", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(3);
expect(screen.getByText(/Brutto = mit Zargen/)).toBeInTheDocument();
expect(screen.getByText(/Netto = nur der Honig/)).toBeInTheDocument();
});
it("sends the harvest with the tapped colonies and weights", async () => {
vi.mocked(createHarvest).mockResolvedValue({ id: "h-1" } as never);
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: "V-02" }));
await userEvent.type(screen.getByLabelText(/Bruttogewicht/), "30.5");
await userEvent.type(screen.getByLabelText(/Nettogewicht/), "22");
await userEvent.click(
screen.getByRole("button", { name: "Ernte speichern" }),
);
const [input, options] = vi.mocked(createHarvest).mock.calls[0];
expect(input).toMatchObject({
apiaryId: "apiary-1",
colonyIds: ["colony-2"],
grossWeight: 30.5,
netWeight: 22,
});
expect(input.honeyType).toBeUndefined();
expect(options?.idempotencyKey).toBeTruthy();
expect(push).toHaveBeenCalledWith("/harvests/h-1");
});
it("shows an inline error and focuses the colonies when none is chosen", async () => {
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
await userEvent.click(
screen.getByRole("button", { name: "Ernte speichern" }),
);
expect(
screen.getByText("Bitte mindestens ein Volk auswählen."),
).toBeInTheDocument();
expect(
screen.getByRole("group", { name: /Beteiligte Völker/ }),
).toHaveFocus();
expect(createHarvest).not.toHaveBeenCalled();
});
});
@@ -12,9 +12,10 @@ import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -25,9 +26,30 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
HarvestFormValues,
initialHarvestValues,
toHarvestInput,
} from "./harvest-form-values";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
type RequiredKey = "apiaryId" | "date" | "colonyIds";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["apiaryId", "date", "colonyIds"];
function validate(values: HarvestFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.apiaryId) errors.apiaryId = "Bitte einen Bienenstand auswählen.";
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (values.colonyIds.length === 0) {
errors.colonyIds = "Bitte mindestens ein Volk auswählen.";
}
return errors;
}
export default function NewHarvestPage() {
@@ -35,21 +57,26 @@ export default function NewHarvestPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const { data: apiaries, isLoading: apiariesLoading } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const [apiaryId, setApiaryId] = useState(searchParams.get("apiaryId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [colonyIds, setColonyIds] = useState<string[]>([]);
const [honeyType, setHoneyType] = useState("");
const [honeySuperCount, setHoneySuperCount] = useState("");
const [grossWeight, setGrossWeight] = useState("");
const [netWeight, setNetWeight] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [values, setValues] = useState<HarvestFormValues>(() =>
initialHarvestValues(searchParams.get("apiaryId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("harvest-new", values, dirty);
const apiaryColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
(colony) =>
colony.apiaryId === values.apiaryId && colony.status === "ACTIVE",
);
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
const { data: withdrawalStatuses } = useQuery({
@@ -61,6 +88,33 @@ export default function NewHarvestPage() {
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
);
function set<K extends keyof HarvestFormValues>(
key: K,
value: HarvestFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
function selectApiary(apiaryId: string) {
// Colonies belong to one apiary - a new apiary starts a new selection.
setValues((current) => ({ ...current, apiaryId, colonyIds: [] }));
setDirty(true);
setFieldErrors((current) => ({ ...current, apiaryId: undefined }));
}
function toggleColony(colonyId: string, checked: boolean) {
set(
"colonyIds",
checked
? [...values.colonyIds, colonyId]
: values.colonyIds.filter((id) => id !== colonyId),
);
}
// 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.
const [idempotencyKey] = useState(newIdempotencyKey);
@@ -68,49 +122,49 @@ export default function NewHarvestPage() {
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
createHarvest(input, { idempotencyKey }),
onSuccess: (harvest) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setError("Ernte konnte nicht gespeichert werden.");
setSubmitError(
errorMessage(mutationError, "Ernte konnte nicht gespeichert werden."),
);
return;
}
void queueHarvest(variables, idempotencyKey).then(() => {
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
draft.clear();
toast.info(
"Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.",
);
router.push("/harvests");
});
},
});
function toggleColony(colonyId: string, checked: boolean) {
setColonyIds((current) =>
checked ? [...current, colonyId] : current.filter((id) => id !== colonyId),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!apiaryId) {
setError("Bitte einen Bienenstand auswählen.");
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
if (colonyIds.length === 0) {
setError("Bitte mindestens ein Volk auswählen.");
return;
mutation.mutate(toHarvestInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
mutation.mutate({
apiaryId,
date: new Date(date).toISOString(),
colonyIds,
honeyType: honeyType || undefined,
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
grossWeight: grossWeight ? Number(grossWeight) : undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
notes: notes || undefined,
});
}
return (
@@ -118,22 +172,47 @@ export default function NewHarvestPage() {
<PageHeader
icon={Boxes}
title="Neue Ernte"
description="Erfasse eine neue Ernte für einen Bienenstand."
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-apiary">Bienenstand</Label>
<Select
value={apiaryId}
onValueChange={(value) => {
setApiaryId(value ?? "");
setColonyIds([]);
}}
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<SelectTrigger id="harvest-apiary" className="h-11 w-full">
<SelectValue placeholder="Bienenstand wählen" />
<FormSection step={1} title="Wo, wann?">
<Field
label="Bienenstand"
required
error={fieldErrors.apiaryId}
>
{({ describedBy, invalid }) => (
<Select
value={values.apiaryId}
onValueChange={(value) => selectApiary(value ?? "")}
>
<SelectTrigger
id="harvest-apiary"
data-field="apiaryId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
apiariesLoading
? "Bienenstände werden geladen…"
: "Bienenstand wählen"
}
/>
</SelectTrigger>
<SelectContent>
{apiaries?.map((apiary) => (
@@ -143,116 +222,183 @@ export default function NewHarvestPage() {
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-date">Datum</Label>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
id="harvest-date"
id={id}
data-field="date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
{apiaryId && apiaryColonies.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine aktiven Völker an diesem Bienenstand.
</p>
) : null}
<div className="flex flex-col gap-2">
)}
</Field>
</FormSection>
<FormSection step={2} title="Welche Völker?">
<Field
label="Beteiligte Völker"
required
error={fieldErrors.colonyIds}
hint={
!values.apiaryId
? "Zuerst einen Bienenstand wählen."
: apiaryColonies.length === 0
? "Keine aktiven Völker an diesem Bienenstand."
: "Alle Völker antippen, deren Honig in dieser Ernte steckt."
}
>
{({ id, labelId, describedBy }) => (
<div
id={id}
role="group"
tabIndex={-1}
data-field="colonyIds"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-2 gap-2 outline-none"
>
{apiaryColonies.map((colony) => {
const withdrawal = withdrawalByColony.get(colony.id);
return (
<div key={colony.id} className="flex flex-col gap-1">
<label
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-colony-${colony.id}`}
>
<input
id={`harvest-colony-${colony.id}`}
type="checkbox"
className="size-5"
checked={colonyIds.includes(colony.id)}
onChange={(event) => toggleColony(colony.id, event.target.checked)}
<ToggleChip
label={colony.code}
checked={values.colonyIds.includes(colony.id)}
onChange={(checked) =>
toggleColony(colony.id, checked)
}
/>
{colony.code}
</label>
{withdrawal ? (
<p className="flex items-center gap-1.5 pl-8 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle
className="size-3.5 shrink-0"
aria-hidden
/>
Wartezeit ({withdrawal.product}) bis{" "}
{new Date(withdrawal.activeUntil).toLocaleDateString("de-DE")}
{new Date(
withdrawal.activeUntil,
).toLocaleDateString("de-DE")}
</p>
) : null}
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-honey-type">Honigsorte</Label>
)}
</Field>
</FormSection>
<FormSection step={3} title="Wie viel?">
<Field label="Honigsorte" hint="z. B. „Frühtracht“, „Raps“">
{({ id, describedBy }) => (
<Input
id="harvest-honey-type"
id={id}
aria-describedby={describedBy}
className="h-11"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
value={values.honeyType}
onChange={(event) => set("honeyType", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-super-count">Honigräume</Label>
)}
</Field>
<Field label="Honigräume" hint="Anzahl abgenommener Honigzargen.">
{({ id, describedBy }) => (
<Input
id="harvest-super-count"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={honeySuperCount}
onChange={(event) => setHoneySuperCount(event.target.value)}
value={values.honeySuperCount}
onChange={(event) =>
set("honeySuperCount", event.target.value)
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-gross-weight">Bruttogewicht (kg)</Label>
)}
</Field>
<Field
label="Bruttogewicht (kg)"
hint="Brutto = mit Zargen bzw. Eimer gewogen."
>
{({ id, describedBy }) => (
<Input
id="harvest-gross-weight"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
step="any"
className="h-11"
value={grossWeight}
onChange={(event) => setGrossWeight(event.target.value)}
value={values.grossWeight}
onChange={(event) => set("grossWeight", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-net-weight">Nettogewicht (kg)</Label>
)}
</Field>
<Field
label="Nettogewicht (kg)"
hint="Netto = nur der Honig, ohne Zargen oder Eimer."
>
{({ id, describedBy }) => (
<Input
id="harvest-net-weight"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
step="any"
className="h-11"
value={netWeight}
onChange={(event) => setNetWeight(event.target.value)}
value={values.netWeight}
onChange={(event) => set("netWeight", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-notes">Notizen</Label>
)}
</Field>
</FormSection>
<FormSection
step={4}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={1}
>
<Field label="Notizen">
{({ id }) => (
<Textarea
id="harvest-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -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 NewQueenPage from "./page";
import { createQueen, fetchQueens } from "@/lib/queens/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
}));
vi.mock("@/lib/queens/api", () => ({
createQueen: vi.fn(),
fetchQueens: vi.fn(),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewQueenPage />
</QueryClientProvider>,
);
}
describe("NewQueenPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchQueens).mockResolvedValue([] as never);
vi.mocked(createQueen).mockReset();
});
it("marks only the code as required and explains the marking colour", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(1);
expect(screen.getByText(/Farbe zeigt das Schlupfjahr/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Zucht\/Rasse/)).not.toBeInTheDocument();
});
it("proposes the marking colour from the hatch year and sends the queen", async () => {
vi.mocked(createQueen).mockResolvedValue({ id: "q-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Code/), "K-2026-03");
await userEvent.type(screen.getByLabelText(/Schlupfjahr/), "2026");
expect(screen.getByRole("radio", { name: "Weiß" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(
screen.getByRole("button", { name: "Königin anlegen" }),
);
const input = vi.mocked(createQueen).mock.calls[0][0];
expect(input).toMatchObject({
code: "K-2026-03",
hatchYear: 2026,
markingColor: "Weiß",
});
expect(input.motherQueenId).toBeUndefined();
expect(push).toHaveBeenCalledWith("/queens/q-1");
});
it("shows an inline error and focuses the code when it is missing", async () => {
renderPage();
await userEvent.click(
screen.getByRole("button", { name: "Königin anlegen" }),
);
expect(screen.getByText("Bitte einen Code angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/^Code/)).toHaveFocus();
expect(createQueen).not.toHaveBeenCalled();
});
});
@@ -3,12 +3,14 @@
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Crown } from "lucide-react";
import { createQueen, fetchQueens } from "@/lib/queens/api";
import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -18,27 +20,56 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 {
MARKING_COLOR_OPTIONS,
QueenFormValues,
initialQueenValues,
toQueenInput,
withHatchYear,
} from "./queen-form-values";
type RequiredKey = "code";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["code"];
const MIN_HATCH_YEAR = 2000;
function validate(values: QueenFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.code.trim()) errors.code = "Bitte einen Code angeben.";
return errors;
}
export default function NewQueenPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [code, setCode] = useState("");
const [hatchYear, setHatchYear] = useState("");
const [breed, setBreed] = useState("");
const [origin, setOrigin] = useState("");
const [markingColor, setMarkingColor] = useState("");
const [markingNumber, setMarkingNumber] = useState("");
const [motherQueenId, setMotherQueenId] = useState("");
const [error, setError] = useState<string | null>(null);
function handleHatchYearChange(value: string) {
setHatchYear(value);
const year = Number(value);
if (!markingColor && value && Number.isInteger(year)) {
setMarkingColor(suggestMarkingColorForYear(year));
const [values, setValues] = useState<QueenFormValues>(initialQueenValues);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("queen-new", values, dirty);
function set<K extends keyof QueenFormValues>(
key: K,
value: QueenFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
function handleHatchYearChange(hatchYear: string) {
setValues((current) => withHatchYear(current, hatchYear));
setDirty(true);
}
const { data: queens } = useQuery({
queryKey: ["queens"],
queryFn: () => fetchQueens(),
@@ -47,26 +78,39 @@ export default function NewQueenPage() {
const mutation = useMutation({
mutationFn: createQueen,
onSuccess: (queen) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["queens"] });
toast.success("Königin angelegt");
router.push(`/queens/${queen.id}`);
},
onError: () => {
setError("Königin konnte nicht angelegt werden.");
onError: (mutationError) => {
setSubmitError(
errorMessage(mutationError, "Königin konnte nicht angelegt werden."),
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
code,
hatchYear: hatchYear ? Number(hatchYear) : undefined,
breed: breed || undefined,
origin: origin || undefined,
markingColor: markingColor || undefined,
markingNumber: markingNumber || undefined,
motherQueenId: motherQueenId || undefined,
});
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate(toQueenInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
return (
@@ -74,84 +118,178 @@ export default function NewQueenPage() {
<PageHeader
icon={Crown}
title="Neue Königin"
description="Erfasse eine neue Königin für deine Zuchtlinie."
description="Nur der Code ist Pflicht."
gradient="var(--gradient-sage)"
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-code">Code</Label>
<Input
id="queen-code"
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<FormSection step={1} title="Welche Königin?">
<Field
label="Code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-hatch-year">Schlupfjahr</Label>
error={fieldErrors.code}
hint="Dein eigenes Kürzel, z. B. „K-2026-03“."
>
{({ id, describedBy, invalid }) => (
<Input
id="queen-hatch-year"
id={id}
data-field="code"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.code}
onChange={(event) => set("code", event.target.value)}
/>
)}
</Field>
<Field label="Schlupfjahr" hint="Jahr, in dem die Königin geschlüpft ist.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
value={hatchYear}
onChange={(event) => handleHatchYearChange(event.target.value)}
min={MIN_HATCH_YEAR}
className="h-11"
value={values.hatchYear}
onChange={(event) =>
handleHatchYearChange(event.target.value)
}
/>
)}
</Field>
</FormSection>
<FormSection
step={2}
title="Zeichnung"
description="Ein Farbpunkt auf dem Rücken macht die Königin auf der Wabe leicht auffindbar. Die Farbe zeigt das Schlupfjahr."
>
<Field
label="Zeichenfarbe"
hint="Wird nach internationaler Konvention aus dem Schlupfjahr vorgeschlagen (letzte Ziffer: 1/6 Weiß, 2/7 Gelb, 3/8 Rot, 4/9 Grün, 5/0 Blau), kann aber angepasst werden. Nochmal tippen hebt die Auswahl auf."
>
{({ id, labelId, describedBy }) => (
<div
id={id}
role="radiogroup"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-3 gap-2 sm:grid-cols-5"
>
{MARKING_COLOR_OPTIONS.map((option) => {
const selected = values.markingColor === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() =>
set("markingColor", selected ? "" : option)
}
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}
</button>
);
})}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
)}
</Field>
<Field
label="Zeichennummer"
hint="Nummer auf dem Plättchen, falls eines aufgeklebt ist."
>
{({ id, describedBy }) => (
<Input
id="queen-breed"
value={breed}
onChange={(event) => setBreed(event.target.value)}
id={id}
aria-describedby={describedBy}
inputMode="numeric"
className="h-11"
value={values.markingNumber}
onChange={(event) =>
set("markingNumber", event.target.value)
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-origin">Herkunft</Label>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Herkunft & Abstammung"
collapsible
defaultOpen={false}
fieldCount={3}
>
<Field label="Zucht/Rasse" hint="z. B. „Carnica“, „Buckfast“">
{({ id, describedBy }) => (
<Input
id="queen-origin"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.breed}
onChange={(event) => set("breed", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-marking-color">Zeichenfarbe</Label>
)}
</Field>
<Field
label="Herkunft"
hint="Woher die Königin stammt, z. B. Züchter oder eigene Nachzucht."
>
{({ id, describedBy }) => (
<Input
id="queen-marking-color"
value={markingColor}
onChange={(event) => setMarkingColor(event.target.value)}
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.origin}
onChange={(event) => set("origin", event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Wird nach internationaler Konvention aus dem Schlupfjahr vorgeschlagen, kann
aber angepasst werden.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-marking-number">Zeichennummer</Label>
<Input
id="queen-marking-number"
value={markingNumber}
onChange={(event) => setMarkingNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-mother">Mutter</Label>
)}
</Field>
<Field label="Mutter">
{({ id }) => (
<Select
value={motherQueenId || "NONE"}
value={values.motherQueenId || "NONE"}
onValueChange={(value) =>
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
set(
"motherQueenId",
value === "NONE" ? "" : (value ?? ""),
)
}
>
<SelectTrigger id="queen-mother" className="w-full">
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Keine Mutter hinterlegt">
{motherQueenId
? queens?.find((option) => option.id === motherQueenId)?.code
{values.motherQueenId
? queens?.find(
(option) => option.id === values.motherQueenId,
)?.code
: "Keine Mutter hinterlegt"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
<SelectItem value="NONE">
Keine Mutter hinterlegt
</SelectItem>
{queens?.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.code}
@@ -159,15 +297,30 @@ export default function NewQueenPage() {
))}
</SelectContent>
</Select>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Königin anlegen"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,71 @@
import type { createQueen } from "@/lib/queens/api";
import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
/** Everything the form holds, as plain values that survive a draft save. */
export interface QueenFormValues {
code: string;
hatchYear: string;
breed: string;
origin: string;
markingColor: string;
markingNumber: string;
motherQueenId: string;
}
/** The five colours of the international convention, in year order. */
export const MARKING_COLOR_OPTIONS = ["Weiß", "Gelb", "Rot", "Grün", "Blau"];
export function initialQueenValues(): QueenFormValues {
return {
code: "",
hatchYear: "",
breed: "",
origin: "",
markingColor: "",
markingNumber: "",
motherQueenId: "",
};
}
function suggestionFor(hatchYear: string): string | null {
// Only a complete year: typing "2026" must not lock in the colour for "2".
if (!/^\d{4}$/.test(hatchYear.trim())) return null;
return suggestMarkingColorForYear(Number(hatchYear));
}
/**
* Sets the hatch year and proposes the matching marking colour - unless a
* colour was chosen by hand (anything other than the previous proposal).
*/
export function withHatchYear(
values: QueenFormValues,
hatchYear: string,
): QueenFormValues {
const previousSuggestion = suggestionFor(values.hatchYear);
const chosenByHand =
values.markingColor !== "" && values.markingColor !== previousSuggestion;
const suggestion = suggestionFor(hatchYear);
return {
...values,
hatchYear,
markingColor:
chosenByHand || !suggestion ? values.markingColor : suggestion,
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
export function toQueenInput(
values: QueenFormValues,
): Parameters<typeof createQueen>[0] {
return {
code: values.code.trim(),
hatchYear: values.hatchYear.trim() ? Number(values.hatchYear) : undefined,
breed: textOrUndefined(values.breed),
origin: textOrUndefined(values.origin),
markingColor: textOrUndefined(values.markingColor),
markingNumber: textOrUndefined(values.markingNumber),
motherQueenId: values.motherQueenId || undefined,
};
}
@@ -0,0 +1,93 @@
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 NewSwarmControlCheckPage from "./page";
import { createSwarmControlCheck } from "@/lib/swarm-control/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/swarm-control/api", () => ({
createSwarmControlCheck: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueSwarmControlCheck: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewSwarmControlCheckPage />
</QueryClientProvider>,
);
}
describe("NewSwarmControlCheckPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createSwarmControlCheck).mockReset();
});
it("marks colony and date as required and keeps notes folded", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
expect(screen.queryByLabelText(/Notizen/)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Weitere Angaben/ }),
).toHaveAttribute("aria-expanded", "false");
});
it("records cells, mood and action with single taps", async () => {
vi.mocked(createSwarmControlCheck).mockResolvedValue({
id: "s-1",
colonyId: "colony-1",
} as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Weiselzellen gefunden/), "3");
await userEvent.click(
screen.getByRole("checkbox", { name: /bereits verdeckelt/ }),
);
await userEvent.click(screen.getByRole("radio", { name: "Hoch" }));
await userEvent.click(
screen.getByRole("radio", { name: "Kunstschwarm gebildet" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Kontrolle speichern" }),
);
const [input, options] = vi.mocked(createSwarmControlCheck).mock.calls[0];
expect(input).toMatchObject({
colonyId: "colony-1",
queenCellsFound: 3,
queenCellsCapped: true,
swarmMood: "HIGH",
actionTaken: "ARTIFICIAL_SWARM",
});
expect(input.nextCheckDate).toBeUndefined();
expect(options?.idempotencyKey).toBeTruthy();
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("shows an inline error and focuses the date when it is missing", async () => {
renderPage();
await userEvent.clear(screen.getByLabelText(/^Datum/));
await userEvent.click(
screen.getByRole("button", { name: "Kontrolle speichern" }),
);
expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createSwarmControlCheck).not.toHaveBeenCalled();
});
});
@@ -7,14 +7,19 @@ import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import {
SWARM_ACTION_LABELS,
SWARM_MOOD_LABELS,
} from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
import { ApiError } from "@/lib/auth/api-client";
import { queueSwarmControlCheck } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -25,9 +30,38 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
SwarmControlFormValues,
initialSwarmControlValues,
toSwarmControlInput,
} from "./swarm-control-form-values";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
type RequiredKey = "colonyId" | "date";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
const SWARM_MOODS = Object.keys(SWARM_MOOD_LABELS) as SwarmMood[];
const SWARM_ACTIONS = Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[];
function validate(values: SwarmControlFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
if (!values.date) errors.date = "Bitte ein Datum angeben.";
return errors;
}
function tileClass(selected: boolean) {
return 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",
);
}
export default function NewSwarmControlCheckPage() {
@@ -35,21 +69,31 @@ export default function NewSwarmControlCheckPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies } = useQuery({
const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const activeColonies =
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [queenCellsFound, setQueenCellsFound] = useState("");
const [queenCellsCapped, setQueenCellsCapped] = useState(false);
const [swarmMood, setSwarmMood] = useState<SwarmMood>("NONE");
const [actionTaken, setActionTaken] = useState<SwarmControlAction>("NONE");
const [nextCheckDate, setNextCheckDate] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [values, setValues] = useState<SwarmControlFormValues>(() =>
initialSwarmControlValues(searchParams.get("colonyId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("swarm-control-new", values, dirty);
function set<K extends keyof SwarmControlFormValues>(
key: K,
value: SwarmControlFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
@@ -58,6 +102,7 @@ export default function NewSwarmControlCheckPage() {
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
createSwarmControlCheck(input, { idempotencyKey }),
onSuccess: (check) => {
draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
@@ -66,11 +111,19 @@ export default function NewSwarmControlCheckPage() {
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
setSubmitError(
errorMessage(
mutationError,
"Schwarmkontrolle konnte nicht gespeichert werden.",
),
);
return;
}
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.");
draft.clear();
toast.info(
"Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
@@ -78,21 +131,25 @@ export default function NewSwarmControlCheckPage() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!colonyId) {
setError("Bitte ein Volk auswählen.");
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate({
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
swarmMood,
actionTaken,
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
notes: notes || undefined,
});
mutation.mutate(toSwarmControlInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
return (
@@ -100,17 +157,53 @@ export default function NewSwarmControlCheckPage() {
<PageHeader
icon={Sprout}
title="Neue Schwarmkontrolle"
description="Erfasse eine Weiselzellenkontrolle für ein Volk."
description="Nur Volk und Datum sind Pflicht – dazu, was du an Weiselzellen gefunden hast."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="swarm-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<FormSection step={1} title="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="swarm-colony"
data-field="colonyId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
@@ -120,104 +213,168 @@ export default function NewSwarmControlCheckPage() {
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-date">Datum</Label>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
id="swarm-date"
id={id}
data-field="date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-cells-found">Weiselzellen gefunden</Label>
)}
</Field>
</FormSection>
<FormSection step={2} title="Was hast du gefunden?">
<Field
label="Weiselzellen gefunden"
hint="Zellen, in denen eine neue Königin heranwächst – groß, zapfenförmig, meist am Wabenrand."
>
{({ id, describedBy }) => (
<Input
id="swarm-cells-found"
id={id}
aria-describedby={describedBy}
type="number"
min={0}
inputMode="numeric"
min={0}
className="h-11"
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-3 text-sm" htmlFor="swarm-cells-capped">
<input
id="swarm-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)}
/>
Weiselzellen bereits verdeckelt (akut)
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-mood">Schwarmstimmung</Label>
<Select
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id="swarm-mood" className="h-11 w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-action">Maßnahme</Label>
<Select
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
value={values.queenCellsFound}
onChange={(event) =>
set("queenCellsFound", event.target.value)
}
/>
)}
</Field>
<ToggleChip
label="Weiselzellen bereits verdeckelt (akut)"
checked={values.queenCellsCapped}
onChange={(checked) => set("queenCellsCapped", checked)}
/>
<Field
label="Schwarmstimmung"
hint="Wie sehr das Volk aufs Schwärmen aus ist."
>
<SelectTrigger id="swarm-action" className="h-11 w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
{({ id, labelId, describedBy }) => (
<div
id={id}
role="radiogroup"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-3 gap-2"
>
{SWARM_MOODS.map((option) => {
const selected = values.swarmMood === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("swarmMood", option)}
className={tileClass(selected)}
>
{SWARM_MOOD_LABELS[option]}
</button>
);
})}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-next-check">Nächste Kontrolle</Label>
)}
</Field>
</FormSection>
<FormSection step={3} title="Was hast du getan?">
<Field label="Maßnahme">
{({ id, labelId }) => (
<div
id={id}
role="radiogroup"
aria-labelledby={labelId}
className="grid grid-cols-2 gap-2"
>
{SWARM_ACTIONS.map((option) => {
const selected = values.actionTaken === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("actionTaken", option)}
className={tileClass(selected)}
>
{SWARM_ACTION_LABELS[option]}
</button>
);
})}
</div>
)}
</Field>
<Field
label="Nächste Kontrolle"
hint="Meist nach 7 Tagen – so lange braucht eine neue Weiselzelle bis zum Verdeckeln."
>
{({ id, describedBy }) => (
<Input
id="swarm-next-check"
id={id}
aria-describedby={describedBy}
type="date"
className="h-11"
value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)}
value={values.nextCheckDate}
onChange={(event) =>
set("nextCheckDate", event.target.value)
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-notes">Notizen</Label>
)}
</Field>
</FormSection>
<FormSection
step={4}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={1}
>
<Field label="Notizen">
{({ id }) => (
<Textarea
id="swarm-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Kontrolle speichern"}
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending
? "Wird gespeichert…"
: "Kontrolle speichern"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,52 @@
import type { createSwarmControlCheck } from "@/lib/swarm-control/api";
import type { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface SwarmControlFormValues {
colonyId: string;
date: string;
queenCellsFound: string;
queenCellsCapped: boolean;
swarmMood: SwarmMood;
actionTaken: SwarmControlAction;
nextCheckDate: string;
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialSwarmControlValues(
colonyId: string,
): SwarmControlFormValues {
return {
colonyId,
date: todayIsoDate(),
queenCellsFound: "",
queenCellsCapped: false,
swarmMood: "NONE",
actionTaken: "NONE",
nextCheckDate: "",
notes: "",
};
}
export function toSwarmControlInput(
values: SwarmControlFormValues,
): Parameters<typeof createSwarmControlCheck>[0] {
return {
colonyId: values.colonyId,
date: new Date(values.date).toISOString(),
queenCellsFound: values.queenCellsFound.trim()
? Number(values.queenCellsFound)
: undefined,
queenCellsCapped: values.queenCellsCapped,
swarmMood: values.swarmMood,
actionTaken: values.actionTaken,
nextCheckDate: values.nextCheckDate
? new Date(values.nextCheckDate).toISOString()
: undefined,
notes: values.notes.trim() ? values.notes.trim() : undefined,
};
}
@@ -0,0 +1,73 @@
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 NewTaskPage from "./page";
import { createTask } from "@/lib/tasks/api";
import { fetchUsers } from "@/lib/users/api";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
}));
vi.mock("@/lib/users/api", () => ({ fetchUsers: vi.fn() }));
vi.mock("@/lib/tasks/api", () => ({ createTask: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewTaskPage />
</QueryClientProvider>,
);
}
describe("NewTaskPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchUsers).mockResolvedValue([] as never);
vi.mocked(createTask).mockReset();
});
it("marks only the title as required and keeps the rest folded", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(1);
expect(screen.queryByLabelText(/Beschreibung/)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Weitere Angaben/ }),
).toHaveAttribute("aria-expanded", "false");
});
it("sends the task with title and tapped priority", async () => {
vi.mocked(createTask).mockResolvedValue({ id: "t-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Titel/), "Honigraum aufsetzen");
await userEvent.click(screen.getByRole("radio", { name: "Dringend" }));
await userEvent.click(
screen.getByRole("button", { name: "Aufgabe anlegen" }),
);
const input = vi.mocked(createTask).mock.calls[0][0];
expect(input).toMatchObject({
title: "Honigraum aufsetzen",
priority: "URGENT",
});
expect(input.dueDate).toBeUndefined();
expect(push).toHaveBeenCalledWith("/tasks");
});
it("shows an inline error and focuses the title when it is missing", async () => {
renderPage();
await userEvent.click(
screen.getByRole("button", { name: "Aufgabe anlegen" }),
);
expect(screen.getByText("Bitte einen Titel angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/^Titel/)).toHaveFocus();
expect(createTask).not.toHaveBeenCalled();
});
});
+183 -71
View File
@@ -6,12 +6,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { createTask } from "@/lib/tasks/api";
import { TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -22,16 +23,36 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
TaskFormValues,
initialTaskValues,
toTaskInput,
} from "./task-form-values";
type RequiredKey = "title";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["title"];
function validate(values: TaskFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.title.trim()) errors.title = "Bitte einen Titel angeben.";
return errors;
}
export default function NewTaskPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TaskPriority>("NORMAL");
const [dueDate, setDueDate] = useState("");
const [assignedUserId, setAssignedUserId] = useState("");
const [error, setError] = useState<string | null>(null);
const [values, setValues] = useState<TaskFormValues>(initialTaskValues);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("task-new", values, dirty);
// Assigning a task requires users:read, which not every role has - if the
// request fails, the assignee field below is simply omitted.
@@ -41,28 +62,53 @@ export default function NewTaskPage() {
retry: false,
});
function set<K extends keyof TaskFormValues>(
key: K,
value: TaskFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
const mutation = useMutation({
mutationFn: createTask,
onSuccess: () => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Aufgabe angelegt");
router.push("/tasks");
},
onError: () => {
setError("Aufgabe konnte nicht angelegt werden.");
onError: (mutationError) => {
setSubmitError(
errorMessage(mutationError, "Aufgabe konnte nicht angelegt werden."),
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
title,
description: description || undefined,
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
assignedUserId: assignedUserId || undefined,
});
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate(toTaskInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
return (
@@ -70,53 +116,112 @@ export default function NewTaskPage() {
<PageHeader
icon={ListTodo}
title="Neue Aufgabe"
description="Lege eine neue Aufgabe an."
description="Nur der Titel ist Pflicht."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="task-title">Titel</Label>
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<FormSection step={1} title="Was ist zu tun?">
<Field
label="Titel"
required
error={fieldErrors.title}
hint="Kurz, z. B. „Honigraum aufsetzen“"
>
{({ id, describedBy, invalid }) => (
<Input
id="task-title"
id={id}
data-field="title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.title}
onChange={(event) => set("title", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-priority">Priorität</Label>
<Select
value={priority}
onValueChange={(value) => setPriority((value ?? "NORMAL") as TaskPriority)}
)}
</Field>
<Field label="Priorität">
{({ id, labelId }) => (
<div
id={id}
role="radiogroup"
aria-labelledby={labelId}
className="grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{TASK_PRIORITIES.map((option) => {
const selected = values.priority === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("priority", option)}
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",
)}
>
<SelectTrigger id="task-priority">
<SelectValue>{TASK_PRIORITY_LABELS[priority]}</SelectValue>
</SelectTrigger>
<SelectContent>
{TASK_PRIORITIES.map((option) => (
<SelectItem key={option} value={option}>
{TASK_PRIORITY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</button>
);
})}
</div>
)}
</Field>
<Field label="Fällig am">
{({ id }) => (
<Input
id={id}
type="date"
className="h-11"
value={values.dueDate}
onChange={(event) => set("dueDate", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={2}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={users ? 2 : 1}
>
{users ? (
<div className="flex flex-col gap-2">
<Label htmlFor="task-assignee">Zugewiesen an</Label>
<Field label="Zugewiesen an">
{({ id }) => (
<Select
value={assignedUserId || "NONE"}
value={values.assignedUserId || "NONE"}
onValueChange={(value) =>
setAssignedUserId(value === "NONE" ? "" : (value ?? ""))
set(
"assignedUserId",
value === "NONE" ? "" : (value ?? ""),
)
}
>
<SelectTrigger id="task-assignee">
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Niemand zugewiesen">
{assignedUserId
? (users.find((user) => user.id === assignedUserId)?.name ??
"Niemand zugewiesen")
{values.assignedUserId
? (users.find(
(user) => user.id === values.assignedUserId,
)?.name ?? "Niemand zugewiesen")
: "Niemand zugewiesen"}
</SelectValue>
</SelectTrigger>
@@ -129,33 +234,40 @@ export default function NewTaskPage() {
))}
</SelectContent>
</Select>
</div>
)}
</Field>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="task-due-date">Fällig am</Label>
<Input
id="task-due-date"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-description">Beschreibung</Label>
<Field label="Beschreibung">
{({ id }) => (
<Textarea
id="task-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
id={id}
value={values.description}
onChange={(event) => set("description", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird angelegt…" : "Aufgabe anlegen"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,37 @@
import type { createTask } from "@/lib/tasks/api";
import type { TaskPriority } from "@/lib/tasks/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface TaskFormValues {
title: string;
description: string;
priority: TaskPriority;
dueDate: string;
assignedUserId: string;
}
export function initialTaskValues(): TaskFormValues {
return {
title: "",
description: "",
priority: "NORMAL",
dueDate: "",
assignedUserId: "",
};
}
export function toTaskInput(
values: TaskFormValues,
): Parameters<typeof createTask>[0] {
return {
title: values.title.trim(),
description: values.description.trim()
? values.description.trim()
: undefined,
priority: values.priority,
dueDate: values.dueDate
? new Date(values.dueDate).toISOString()
: undefined,
assignedUserId: values.assignedUserId || undefined,
};
}
@@ -0,0 +1,88 @@
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 NewVarroaMeasurementPage from "./page";
import { createVarroaMeasurement } from "@/lib/varroa/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/varroa/api", () => ({ createVarroaMeasurement: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewVarroaMeasurementPage />
</QueryClientProvider>,
);
}
describe("NewVarroaMeasurementPage", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createVarroaMeasurement).mockReset();
});
it("marks colony and date as required and explains the daily mite fall", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
expect(screen.getByText(/Milbenfall pro Tag/)).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: "Natürlicher Milbenfall" }),
).toHaveAttribute("aria-checked", "true");
expect(screen.queryByLabelText(/Einschätzung/)).not.toBeInTheDocument();
});
it("sends the measurement with method and numbers", async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({
id: "v-1",
colonyId: "colony-1",
} as never);
renderPage();
await userEvent.click(
screen.getByRole("radio", { name: "Puderzuckermethode" }),
);
await userEvent.type(screen.getByLabelText(/Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/Zeitraum/), "5");
await userEvent.click(
screen.getByRole("button", { name: "Messung speichern" }),
);
const [input, options] = vi.mocked(createVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({
colonyId: "colony-1",
method: "POWDERED_SUGAR",
miteCount: 12,
durationMinutes: 5,
});
expect(input.assessment).toBeUndefined();
expect(options?.idempotencyKey).toBeTruthy();
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("shows an inline error and focuses the date when it is missing", async () => {
renderPage();
await userEvent.clear(screen.getByLabelText(/^Datum/));
await userEvent.click(
screen.getByRole("button", { name: "Messung speichern" }),
);
expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createVarroaMeasurement).not.toHaveBeenCalled();
});
});
@@ -7,14 +7,15 @@ import { toast } from "sonner";
import { Bug } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueVarroaMeasurement } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -25,9 +26,27 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
VARROA_METHOD_HINTS,
VarroaFormValues,
initialVarroaValues,
toVarroaInput,
} from "./varroa-form-values";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
type RequiredKey = "colonyId" | "date";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
function validate(values: VarroaFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
if (!values.date) errors.date = "Bitte ein Datum angeben.";
return errors;
}
export default function NewVarroaMeasurementPage() {
@@ -35,20 +54,31 @@ export default function NewVarroaMeasurementPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies } = useQuery({
const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const activeColonies =
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [method, setMethod] = useState<VarroaMethod>("NATURAL_MITE_FALL");
const [miteCount, setMiteCount] = useState("");
const [durationMinutes, setDurationMinutes] = useState("");
const [assessment, setAssessment] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [values, setValues] = useState<VarroaFormValues>(() =>
initialVarroaValues(searchParams.get("colonyId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("varroa-new", values, dirty);
function set<K extends keyof VarroaFormValues>(
key: K,
value: VarroaFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
@@ -57,6 +87,7 @@ export default function NewVarroaMeasurementPage() {
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
createVarroaMeasurement(input, { idempotencyKey }),
onSuccess: (measurement) => {
draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
@@ -65,11 +96,19 @@ export default function NewVarroaMeasurementPage() {
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setError("Varroamessung konnte nicht gespeichert werden.");
setSubmitError(
errorMessage(
mutationError,
"Varroamessung konnte nicht gespeichert werden.",
),
);
return;
}
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.");
draft.clear();
toast.info(
"Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
@@ -77,38 +116,81 @@ export default function NewVarroaMeasurementPage() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!colonyId) {
setError("Bitte ein Volk auswählen.");
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
mutation.mutate({
colonyId,
date: new Date(date).toISOString(),
method,
miteCount: miteCount ? Number(miteCount) : undefined,
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
assessment: assessment || undefined,
notes: notes || undefined,
});
mutation.mutate(toVarroaInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
setDirty(true);
}
}
const isNaturalFall = values.method === "NATURAL_MITE_FALL";
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Bug}
title="Neue Varroamessung"
description="Erfasse eine neue Varroamessung für ein Volk."
description="Nur Volk und Datum sind Pflicht – dazu, wie und was du gezählt hast."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
/>
) : null}
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="varroa-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
<form
className="flex flex-col gap-6"
onSubmit={handleSubmit}
noValidate
>
<FormSection step={1} title="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="varroa-colony"
data-field="colonyId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
@@ -118,85 +200,156 @@ export default function NewVarroaMeasurementPage() {
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-date">Datum</Label>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
id="varroa-date"
id={id}
data-field="date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-method">Methode</Label>
<Select
value={method}
onValueChange={(value) => setMethod((value ?? "NATURAL_MITE_FALL") as VarroaMethod)}
)}
</Field>
</FormSection>
<FormSection step={2} title="Wie gemessen?">
<Field label="Methode" hint={VARROA_METHOD_HINTS[values.method]}>
{({ id, labelId, describedBy }) => (
<div
id={id}
role="radiogroup"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-2 gap-2"
>
{VARROA_METHODS.map((option) => {
const selected = values.method === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("method", option)}
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",
)}
>
<SelectTrigger id="varroa-method" className="h-11 w-full">
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</SelectTrigger>
<SelectContent>
{VARROA_METHODS.map((option) => (
<SelectItem key={option} value={option}>
{VARROA_METHOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</button>
);
})}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-mite-count">Milbenzahl</Label>
)}
</Field>
</FormSection>
<FormSection step={3} title="Ergebnis">
<Field
label="Milbenzahl"
hint={
isNaturalFall
? "Alle Milben, die im ganzen Zeitraum gefallen sind – nicht pro Tag."
: "Gezählte Milben in der Probe."
}
>
{({ id, describedBy }) => (
<Input
id="varroa-mite-count"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={miteCount}
onChange={(event) => setMiteCount(event.target.value)}
value={values.miteCount}
onChange={(event) => set("miteCount", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-duration">Zeitraum (Minuten)</Label>
)}
</Field>
<Field
label="Zeitraum (Minuten)"
hint={
isNaturalFall
? "Wie lange die Windel lag: 1 Tag = 1440 Minuten, 3 Tage = 4320 Minuten."
: "Wie lange die Messung gedauert hat."
}
>
{({ id, describedBy }) => (
<Input
id="varroa-duration"
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
value={values.durationMinutes}
onChange={(event) =>
set("durationMinutes", event.target.value)
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-assessment">Einschätzung</Label>
)}
</Field>
</FormSection>
<FormSection
step={4}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
>
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
{({ id, describedBy }) => (
<Input
id="varroa-assessment"
id={id}
aria-describedby={describedBy}
className="h-11"
value={assessment}
onChange={(event) => setAssessment(event.target.value)}
value={values.assessment}
onChange={(event) => set("assessment", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-notes">Notizen</Label>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea
id="varroa-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
)}
</Field>
</FormSection>
<StickySubmit
note={
submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
) : null
}
>
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Messung speichern"}
</Button>
</StickySubmit>
</form>
</CardContent>
</Card>
@@ -0,0 +1,60 @@
import type { createVarroaMeasurement } from "@/lib/varroa/api";
import type { VarroaMethod } from "@/lib/varroa/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface VarroaFormValues {
colonyId: string;
date: string;
method: VarroaMethod;
miteCount: string;
durationMinutes: string;
assessment: string;
notes: string;
}
/** One plain sentence per method: what is counted and how to read it. */
export const VARROA_METHOD_HINTS: Record<VarroaMethod, string> = {
NATURAL_MITE_FALL:
"Milben auf der Windel unter dem Gitterboden zählen. Gezählte Milben durch die Anzahl Tage teilen – nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
POWDERED_SUGAR:
"Bienenprobe mit Puderzucker bestäuben und schütteln, herausgefallene Milben zählen.",
WASHING_METHOD:
"Bienenprobe auswaschen und die abgelösten Milben zählen.",
DRONE_BROOD_CONTROL:
"Entnommene Drohnenbrut öffnen und befallene Zellen bzw. Milben zählen.",
};
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialVarroaValues(colonyId: string): VarroaFormValues {
return {
colonyId,
date: todayIsoDate(),
method: "NATURAL_MITE_FALL",
miteCount: "",
durationMinutes: "",
assessment: "",
notes: "",
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toVarroaInput(
values: VarroaFormValues,
): Parameters<typeof createVarroaMeasurement>[0] {
return {
colonyId: values.colonyId,
date: new Date(values.date).toISOString(),
method: values.method,
miteCount: numberOrUndefined(values.miteCount),
durationMinutes: numberOrUndefined(values.durationMinutes),
assessment: textOrUndefined(values.assessment),
notes: textOrUndefined(values.notes),
};
}