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:
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 { FormEvent, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||||
import { fetchProducts } from "@/lib/products/api";
|
import { fetchProducts } from "@/lib/products/api";
|
||||||
import { Package } from "lucide-react";
|
|
||||||
import { createBottlingBatch } from "@/lib/bottling/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,22 +21,68 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||||
|
import { DraftBanner } from "@/components/form/draft-banner";
|
||||||
|
import {
|
||||||
|
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() {
|
export default function NewBottlingBatchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [honeyBatchId, setHoneyBatchId] = useState(searchParams.get("honeyBatchId") ?? "");
|
const [values, setValues] = useState<BottlingFormValues>(() =>
|
||||||
const [productId, setProductId] = useState("");
|
initialBottlingValues(searchParams.get("honeyBatchId") ?? ""),
|
||||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
);
|
||||||
const [jarSize, setJarSize] = useState("500");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [jarCount, setJarCount] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [bestBeforeDate, setBestBeforeDate] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [lotNumber, setLotNumber] = useState("");
|
const draft = useFormDraft("bottling-new", values, dirty);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: honeyBatches } = useQuery({
|
const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({
|
||||||
queryKey: ["honey-batches"],
|
queryKey: ["honey-batches"],
|
||||||
queryFn: () => fetchHoneyBatches(),
|
queryFn: () => fetchHoneyBatches(),
|
||||||
});
|
});
|
||||||
@@ -43,30 +91,54 @@ export default function NewBottlingBatchPage() {
|
|||||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createBottlingBatch,
|
mutationFn: createBottlingBatch,
|
||||||
onSuccess: (bottlingBatch) => {
|
onSuccess: (bottlingBatch) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||||
|
toast.success("Abfüllung angelegt");
|
||||||
router.push(`/bottling/${bottlingBatch.id}`);
|
router.push(`/bottling/${bottlingBatch.id}`);
|
||||||
},
|
},
|
||||||
onError: (mutationError: Error) => {
|
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>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const errors = validate(values);
|
||||||
honeyBatchId,
|
setFieldErrors(errors);
|
||||||
productId: productId || undefined,
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
date: new Date(date).toISOString(),
|
if (firstInvalid) {
|
||||||
jarSize: Number(jarSize),
|
document
|
||||||
jarCount: Number(jarCount),
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
?.focus();
|
||||||
lotNumber: lotNumber || undefined,
|
return;
|
||||||
});
|
}
|
||||||
|
mutation.mutate(toBottlingInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,35 +148,164 @@ export default function NewBottlingBatchPage() {
|
|||||||
title="Neue Abfüllung"
|
title="Neue Abfüllung"
|
||||||
description="Fülle eine Honigcharge in Gläser ab."
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="bottling-honey-batch">Honigcharge</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select
|
noValidate
|
||||||
value={honeyBatchId}
|
|
||||||
onValueChange={(value) => setHoneyBatchId(value ?? "")}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="bottling-honey-batch">
|
<FormSection step={1} title="Welcher Honig, wann?">
|
||||||
<SelectValue placeholder="Charge wählen" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{honeyBatches?.map((honeyBatch) => (
|
{honeyBatches?.map((honeyBatch) => (
|
||||||
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
|
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
|
||||||
{honeyBatch.batchNumber}
|
{honeyBatch.batchNumber}
|
||||||
{honeyBatch.amount !== null ? ` · ${honeyBatch.amount} kg` : ""}
|
{honeyBatch.amount !== null
|
||||||
|
? ` · ${honeyBatch.amount} kg`
|
||||||
|
: ""}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="bottling-product">Produkt (optional)</Label>
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
<Select
|
{({ id, describedBy, invalid }) => (
|
||||||
value={productId}
|
<Input
|
||||||
onValueChange={(value) => setProductId(value ?? "")}
|
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" />
|
<SelectValue placeholder="Produkt wählen" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -115,64 +316,45 @@ export default function NewBottlingBatchPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="bottling-date">Datum</Label>
|
<Field
|
||||||
|
label="Los-/Chargennummer"
|
||||||
|
hint="Steht aufs Etikett, damit jedes Glas seiner Charge zugeordnet werden kann."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="bottling-date"
|
id={id}
|
||||||
type="date"
|
aria-describedby={describedBy}
|
||||||
required
|
className="h-11"
|
||||||
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"
|
|
||||||
placeholder="Automatisch, falls Automatisierung aktiv"
|
placeholder="Automatisch, falls Automatisierung aktiv"
|
||||||
value={lotNumber}
|
value={values.lotNumber}
|
||||||
onChange={(event) => setLotNumber(event.target.value)}
|
onChange={(event) => set("lotNumber", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" disabled={mutation.isPending || !honeyBatchId}>
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Abfüllung anlegen"}
|
{mutation.isPending ? "Wird angelegt…" : "Abfüllung anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Droplet } from "lucide-react";
|
||||||
import { fetchHarvests } from "@/lib/harvests/api";
|
import { fetchHarvests } from "@/lib/harvests/api";
|
||||||
import { createExtraction } from "@/lib/extractions/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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { 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 {
|
type RequiredKey = "date" | "harvestIds";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewExtractionPage() {
|
||||||
@@ -23,52 +44,78 @@ export default function NewExtractionPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
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 [values, setValues] = useState<ExtractionFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialExtractionValues(searchParams.get("harvestId")),
|
||||||
const [harvestIds, setHarvestIds] = useState<string[]>(
|
|
||||||
preselectedHarvestId ? [preselectedHarvestId] : [],
|
|
||||||
);
|
);
|
||||||
const [amount, setAmount] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [room, setRoom] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [waterContent, setWaterContent] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const draft = useFormDraft("extraction-new", values, dirty);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createExtraction,
|
mutationFn: createExtraction,
|
||||||
onSuccess: (extraction) => {
|
onSuccess: (extraction) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
|
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
|
||||||
toast.success("Schleuderung gespeichert");
|
toast.success("Schleuderung gespeichert");
|
||||||
router.push(`/extractions/${extraction.id}`);
|
router.push(`/extractions/${extraction.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Schleuderung konnte nicht gespeichert werden.");
|
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>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (harvestIds.length === 0) {
|
const errors = validate(values);
|
||||||
setError("Bitte mindestens eine Ernte auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutation.mutate({
|
mutation.mutate(toExtractionInput(values));
|
||||||
date: new Date(date).toISOString(),
|
}
|
||||||
harvestIds,
|
|
||||||
amount: amount ? Number(amount) : undefined,
|
function restoreDraft() {
|
||||||
room: room || undefined,
|
const restored = draft.restore();
|
||||||
waterContent: waterContent ? Number(waterContent) : undefined,
|
if (restored) {
|
||||||
notes: notes || undefined,
|
setValues(restored);
|
||||||
});
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,97 +123,164 @@ export default function NewExtractionPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Droplet}
|
icon={Droplet}
|
||||||
title="Neue Schleuderung"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="extraction-date">Datum</Label>
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormSection step={1} title="Wann, aus welchen Ernten?">
|
||||||
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
|
{({ id, describedBy, invalid }) => (
|
||||||
<Input
|
<Input
|
||||||
id="extraction-date"
|
id={id}
|
||||||
|
data-field="date"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={date}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setDate(event.target.value)}
|
aria-invalid={invalid}
|
||||||
|
value={values.date}
|
||||||
|
onChange={(event) => set("date", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label>Ernten</Label>
|
<Field
|
||||||
{harvests?.length === 0 ? (
|
label="Ernten"
|
||||||
<p className="text-sm text-muted-foreground">Noch keine Ernten vorhanden.</p>
|
required
|
||||||
) : null}
|
error={fieldErrors.harvestIds}
|
||||||
<div className="flex flex-col gap-2">
|
hint={
|
||||||
{harvests?.map((harvest) => (
|
harvests?.length === 0
|
||||||
<label
|
? "Noch keine Ernten vorhanden – zuerst eine Ernte erfassen."
|
||||||
key={harvest.id}
|
: "Alle Ernten antippen, deren Waben du geschleudert hast."
|
||||||
className="flex h-11 items-center gap-3 text-sm"
|
}
|
||||||
htmlFor={`extraction-harvest-${harvest.id}`}
|
|
||||||
>
|
>
|
||||||
<input
|
{({ id, labelId, describedBy }) => (
|
||||||
id={`extraction-harvest-${harvest.id}`}
|
<div
|
||||||
type="checkbox"
|
id={id}
|
||||||
className="size-5"
|
role="group"
|
||||||
checked={harvestIds.includes(harvest.id)}
|
tabIndex={-1}
|
||||||
onChange={(event) => toggleHarvest(harvest.id, event.target.checked)}
|
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>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="extraction-amount">Menge (kg)</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection step={2} title="Ergebnis">
|
||||||
|
<Field label="Menge (kg)" hint="Geschleuderter Honig, ohne Eimer.">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="extraction-amount"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min={0}
|
min={0}
|
||||||
|
step="any"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={amount}
|
value={values.amount}
|
||||||
onChange={(event) => setAmount(event.target.value)}
|
onChange={(event) => set("amount", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="extraction-room">Schleuderraum</Label>
|
<Field
|
||||||
|
label="Wassergehalt (%)"
|
||||||
|
hint="Mit dem Refraktometer gemessen. Über 18 % kann der Honig gären."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="extraction-room"
|
id={id}
|
||||||
className="h-11"
|
aria-describedby={describedBy}
|
||||||
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"
|
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
|
step="any"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={waterContent}
|
value={values.waterContent}
|
||||||
onChange={(event) => setWaterContent(event.target.value)}
|
onChange={(event) =>
|
||||||
|
set("waterContent", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="extraction-notes">Notizen</Label>
|
</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
|
<Textarea
|
||||||
id="extraction-notes"
|
id={id}
|
||||||
value={notes}
|
value={values.notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => set("notes", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
}
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Schleuderung speichern"}
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending
|
||||||
|
? "Wird gespeichert…"
|
||||||
|
: "Schleuderung speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueHarvest } from "@/lib/offline/db";
|
import { queueHarvest } from "@/lib/offline/db";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
|
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||||
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,9 +26,30 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { 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 {
|
type RequiredKey = "apiaryId" | "date" | "colonyIds";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewHarvestPage() {
|
||||||
@@ -35,21 +57,26 @@ export default function NewHarvestPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
const { data: apiaries, isLoading: apiariesLoading } = useQuery({
|
||||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
queryKey: ["apiaries"],
|
||||||
|
queryFn: () => fetchApiaries(),
|
||||||
|
});
|
||||||
|
const { data: colonies } = useQuery({
|
||||||
|
queryKey: ["colonies"],
|
||||||
|
queryFn: () => fetchColonies(),
|
||||||
|
});
|
||||||
|
|
||||||
const [apiaryId, setApiaryId] = useState(searchParams.get("apiaryId") ?? "");
|
const [values, setValues] = useState<HarvestFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialHarvestValues(searchParams.get("apiaryId") ?? ""),
|
||||||
const [colonyIds, setColonyIds] = useState<string[]>([]);
|
);
|
||||||
const [honeyType, setHoneyType] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [honeySuperCount, setHoneySuperCount] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [grossWeight, setGrossWeight] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [netWeight, setNetWeight] = useState("");
|
const draft = useFormDraft("harvest-new", values, dirty);
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const apiaryColonies = (colonies ?? []).filter(
|
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 apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
|
||||||
const { data: withdrawalStatuses } = useQuery({
|
const { data: withdrawalStatuses } = useQuery({
|
||||||
@@ -61,6 +88,33 @@ export default function NewHarvestPage() {
|
|||||||
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
|
(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
|
// One key per opened form: a double tap or an offline retry of the
|
||||||
// same entry is recognised by the server instead of saved twice.
|
// same entry is recognised by the server instead of saved twice.
|
||||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||||
@@ -68,49 +122,49 @@ export default function NewHarvestPage() {
|
|||||||
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
|
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
|
||||||
createHarvest(input, { idempotencyKey }),
|
createHarvest(input, { idempotencyKey }),
|
||||||
onSuccess: (harvest) => {
|
onSuccess: (harvest) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
||||||
toast.success("Ernte gespeichert");
|
toast.success("Ernte gespeichert");
|
||||||
router.push(`/harvests/${harvest.id}`);
|
router.push(`/harvests/${harvest.id}`);
|
||||||
},
|
},
|
||||||
onError: (mutationError, variables) => {
|
onError: (mutationError, variables) => {
|
||||||
if (mutationError instanceof ApiError) {
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Ernte konnte nicht gespeichert werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Ernte konnte nicht gespeichert werden."),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueHarvest(variables, idempotencyKey).then(() => {
|
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");
|
router.push("/harvests");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleColony(colonyId: string, checked: boolean) {
|
|
||||||
setColonyIds((current) =>
|
|
||||||
checked ? [...current, colonyId] : current.filter((id) => id !== colonyId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!apiaryId) {
|
const errors = validate(values);
|
||||||
setError("Bitte einen Bienenstand auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (colonyIds.length === 0) {
|
mutation.mutate(toHarvestInput(values));
|
||||||
setError("Bitte mindestens ein Volk auswählen.");
|
}
|
||||||
return;
|
|
||||||
|
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 (
|
return (
|
||||||
@@ -118,22 +172,47 @@ export default function NewHarvestPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Neue Ernte"
|
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">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="harvest-apiary">Bienenstand</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select
|
noValidate
|
||||||
value={apiaryId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setApiaryId(value ?? "");
|
|
||||||
setColonyIds([]);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="harvest-apiary" className="h-11 w-full">
|
<FormSection step={1} title="Wo, wann?">
|
||||||
<SelectValue placeholder="Bienenstand wählen" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{apiaries?.map((apiary) => (
|
{apiaries?.map((apiary) => (
|
||||||
@@ -143,116 +222,183 @@ export default function NewHarvestPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-date">Datum</Label>
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
|
{({ id, describedBy, invalid }) => (
|
||||||
<Input
|
<Input
|
||||||
id="harvest-date"
|
id={id}
|
||||||
|
data-field="date"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={date}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setDate(event.target.value)}
|
aria-invalid={invalid}
|
||||||
|
value={values.date}
|
||||||
|
onChange={(event) => set("date", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label>Beteiligte Völker</Label>
|
</FormSection>
|
||||||
{apiaryId && apiaryColonies.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<FormSection step={2} title="Welche Völker?">
|
||||||
Keine aktiven Völker an diesem Bienenstand.
|
<Field
|
||||||
</p>
|
label="Beteiligte Völker"
|
||||||
) : null}
|
required
|
||||||
<div className="flex flex-col gap-2">
|
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) => {
|
{apiaryColonies.map((colony) => {
|
||||||
const withdrawal = withdrawalByColony.get(colony.id);
|
const withdrawal = withdrawalByColony.get(colony.id);
|
||||||
return (
|
return (
|
||||||
<div key={colony.id} className="flex flex-col gap-1">
|
<div key={colony.id} className="flex flex-col gap-1">
|
||||||
<label
|
<ToggleChip
|
||||||
className="flex h-11 items-center gap-3 text-sm"
|
label={colony.code}
|
||||||
htmlFor={`harvest-colony-${colony.id}`}
|
checked={values.colonyIds.includes(colony.id)}
|
||||||
>
|
onChange={(checked) =>
|
||||||
<input
|
toggleColony(colony.id, checked)
|
||||||
id={`harvest-colony-${colony.id}`}
|
}
|
||||||
type="checkbox"
|
|
||||||
className="size-5"
|
|
||||||
checked={colonyIds.includes(colony.id)}
|
|
||||||
onChange={(event) => toggleColony(colony.id, event.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
{colony.code}
|
|
||||||
</label>
|
|
||||||
{withdrawal ? (
|
{withdrawal ? (
|
||||||
<p className="flex items-center gap-1.5 pl-8 text-xs text-destructive">
|
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
||||||
<AlertTriangle className="size-3.5 shrink-0" />
|
<AlertTriangle
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
Wartezeit ({withdrawal.product}) bis{" "}
|
Wartezeit ({withdrawal.product}) bis{" "}
|
||||||
{new Date(withdrawal.activeUntil).toLocaleDateString("de-DE")}
|
{new Date(
|
||||||
|
withdrawal.activeUntil,
|
||||||
|
).toLocaleDateString("de-DE")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-honey-type">Honigsorte</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection step={3} title="Wie viel?">
|
||||||
|
<Field label="Honigsorte" hint="z. B. „Frühtracht“, „Raps“">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="harvest-honey-type"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={honeyType}
|
value={values.honeyType}
|
||||||
onChange={(event) => setHoneyType(event.target.value)}
|
onChange={(event) => set("honeyType", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-super-count">Honigräume</Label>
|
<Field label="Honigräume" hint="Anzahl abgenommener Honigzargen.">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="harvest-super-count"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={honeySuperCount}
|
value={values.honeySuperCount}
|
||||||
onChange={(event) => setHoneySuperCount(event.target.value)}
|
onChange={(event) =>
|
||||||
|
set("honeySuperCount", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-gross-weight">Bruttogewicht (kg)</Label>
|
<Field
|
||||||
|
label="Bruttogewicht (kg)"
|
||||||
|
hint="Brutto = mit Zargen bzw. Eimer gewogen."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="harvest-gross-weight"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min={0}
|
min={0}
|
||||||
|
step="any"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={grossWeight}
|
value={values.grossWeight}
|
||||||
onChange={(event) => setGrossWeight(event.target.value)}
|
onChange={(event) => set("grossWeight", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-net-weight">Nettogewicht (kg)</Label>
|
<Field
|
||||||
|
label="Nettogewicht (kg)"
|
||||||
|
hint="Netto = nur der Honig, ohne Zargen oder Eimer."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="harvest-net-weight"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min={0}
|
min={0}
|
||||||
|
step="any"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={netWeight}
|
value={values.netWeight}
|
||||||
onChange={(event) => setNetWeight(event.target.value)}
|
onChange={(event) => set("netWeight", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="harvest-notes">Notizen</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
step={4}
|
||||||
|
title="Weitere Angaben"
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
fieldCount={1}
|
||||||
|
>
|
||||||
|
<Field label="Notizen">
|
||||||
|
{({ id }) => (
|
||||||
<Textarea
|
<Textarea
|
||||||
id="harvest-notes"
|
id={id}
|
||||||
value={notes}
|
value={values.notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => set("notes", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
|
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { FormEvent, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Crown } from "lucide-react";
|
import { Crown } from "lucide-react";
|
||||||
import { createQueen, fetchQueens } from "@/lib/queens/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -18,27 +20,56 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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() {
|
export default function NewQueenPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
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) {
|
const [values, setValues] = useState<QueenFormValues>(initialQueenValues);
|
||||||
setHatchYear(value);
|
const [dirty, setDirty] = useState(false);
|
||||||
const year = Number(value);
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
if (!markingColor && value && Number.isInteger(year)) {
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
setMarkingColor(suggestMarkingColorForYear(year));
|
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({
|
const { data: queens } = useQuery({
|
||||||
queryKey: ["queens"],
|
queryKey: ["queens"],
|
||||||
queryFn: () => fetchQueens(),
|
queryFn: () => fetchQueens(),
|
||||||
@@ -47,26 +78,39 @@ export default function NewQueenPage() {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: createQueen,
|
mutationFn: createQueen,
|
||||||
onSuccess: (queen) => {
|
onSuccess: (queen) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
||||||
|
toast.success("Königin angelegt");
|
||||||
router.push(`/queens/${queen.id}`);
|
router.push(`/queens/${queen.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Königin konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Königin konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const errors = validate(values);
|
||||||
code,
|
setFieldErrors(errors);
|
||||||
hatchYear: hatchYear ? Number(hatchYear) : undefined,
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
breed: breed || undefined,
|
if (firstInvalid) {
|
||||||
origin: origin || undefined,
|
document
|
||||||
markingColor: markingColor || undefined,
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
markingNumber: markingNumber || undefined,
|
?.focus();
|
||||||
motherQueenId: motherQueenId || undefined,
|
return;
|
||||||
});
|
}
|
||||||
|
mutation.mutate(toQueenInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,84 +118,178 @@ export default function NewQueenPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Crown}
|
icon={Crown}
|
||||||
title="Neue Königin"
|
title="Neue Königin"
|
||||||
description="Erfasse eine neue Königin für deine Zuchtlinie."
|
description="Nur der Code ist Pflicht."
|
||||||
gradient="var(--gradient-sage)"
|
gradient="var(--gradient-sage)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="queen-code">Code</Label>
|
onSubmit={handleSubmit}
|
||||||
<Input
|
noValidate
|
||||||
id="queen-code"
|
>
|
||||||
|
<FormSection step={1} title="Welche Königin?">
|
||||||
|
<Field
|
||||||
|
label="Code"
|
||||||
required
|
required
|
||||||
value={code}
|
error={fieldErrors.code}
|
||||||
onChange={(event) => setCode(event.target.value)}
|
hint="Dein eigenes Kürzel, z. B. „K-2026-03“."
|
||||||
/>
|
>
|
||||||
</div>
|
{({ id, describedBy, invalid }) => (
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="queen-hatch-year">Schlupfjahr</Label>
|
|
||||||
<Input
|
<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"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={hatchYear}
|
min={MIN_HATCH_YEAR}
|
||||||
onChange={(event) => handleHatchYearChange(event.target.value)}
|
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>
|
||||||
<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
|
<Input
|
||||||
id="queen-breed"
|
id={id}
|
||||||
value={breed}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setBreed(event.target.value)}
|
inputMode="numeric"
|
||||||
|
className="h-11"
|
||||||
|
value={values.markingNumber}
|
||||||
|
onChange={(event) =>
|
||||||
|
set("markingNumber", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="queen-origin">Herkunft</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
step={3}
|
||||||
|
title="Herkunft & Abstammung"
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
fieldCount={3}
|
||||||
|
>
|
||||||
|
<Field label="Zucht/Rasse" hint="z. B. „Carnica“, „Buckfast“">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="queen-origin"
|
id={id}
|
||||||
value={origin}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setOrigin(event.target.value)}
|
className="h-11"
|
||||||
|
value={values.breed}
|
||||||
|
onChange={(event) => set("breed", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="queen-marking-color">Zeichenfarbe</Label>
|
<Field
|
||||||
|
label="Herkunft"
|
||||||
|
hint="Woher die Königin stammt, z. B. Züchter oder eigene Nachzucht."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="queen-marking-color"
|
id={id}
|
||||||
value={markingColor}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setMarkingColor(event.target.value)}
|
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
|
</Field>
|
||||||
aber angepasst werden.
|
<Field label="Mutter">
|
||||||
</p>
|
{({ id }) => (
|
||||||
</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>
|
|
||||||
<Select
|
<Select
|
||||||
value={motherQueenId || "NONE"}
|
value={values.motherQueenId || "NONE"}
|
||||||
onValueChange={(value) =>
|
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">
|
<SelectValue placeholder="Keine Mutter hinterlegt">
|
||||||
{motherQueenId
|
{values.motherQueenId
|
||||||
? queens?.find((option) => option.id === motherQueenId)?.code
|
? queens?.find(
|
||||||
|
(option) => option.id === values.motherQueenId,
|
||||||
|
)?.code
|
||||||
: "Keine Mutter hinterlegt"}
|
: "Keine Mutter hinterlegt"}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
|
<SelectItem value="NONE">
|
||||||
|
Keine Mutter hinterlegt
|
||||||
|
</SelectItem>
|
||||||
{queens?.map((option) => (
|
{queens?.map((option) => (
|
||||||
<SelectItem key={option.id} value={option.id}>
|
<SelectItem key={option.id} value={option.id}>
|
||||||
{option.code}
|
{option.code}
|
||||||
@@ -159,15 +297,30 @@ export default function NewQueenPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
}
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Wird angelegt…" : "Königin anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Sprout } from "lucide-react";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { createSwarmControlCheck } from "@/lib/swarm-control/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 { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueSwarmControlCheck } from "@/lib/offline/db";
|
import { queueSwarmControlCheck } from "@/lib/offline/db";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
|
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||||
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,9 +30,38 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { 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 {
|
type RequiredKey = "colonyId" | "date";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewSwarmControlCheckPage() {
|
||||||
@@ -35,21 +69,31 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: colonies } = useQuery({
|
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||||
queryKey: ["colonies"],
|
queryKey: ["colonies"],
|
||||||
queryFn: () => fetchColonies(),
|
queryFn: () => fetchColonies(),
|
||||||
});
|
});
|
||||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
const activeColonies =
|
||||||
|
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||||
|
|
||||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
const [values, setValues] = useState<SwarmControlFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialSwarmControlValues(searchParams.get("colonyId") ?? ""),
|
||||||
const [queenCellsFound, setQueenCellsFound] = useState("");
|
);
|
||||||
const [queenCellsCapped, setQueenCellsCapped] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [swarmMood, setSwarmMood] = useState<SwarmMood>("NONE");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [actionTaken, setActionTaken] = useState<SwarmControlAction>("NONE");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [nextCheckDate, setNextCheckDate] = useState("");
|
const draft = useFormDraft("swarm-control-new", values, dirty);
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// One key per opened form: a double tap or an offline retry of the
|
||||||
// same entry is recognised by the server instead of saved twice.
|
// same entry is recognised by the server instead of saved twice.
|
||||||
@@ -58,6 +102,7 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
|
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
|
||||||
createSwarmControlCheck(input, { idempotencyKey }),
|
createSwarmControlCheck(input, { idempotencyKey }),
|
||||||
onSuccess: (check) => {
|
onSuccess: (check) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
||||||
});
|
});
|
||||||
@@ -66,11 +111,19 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
},
|
},
|
||||||
onError: (mutationError, variables) => {
|
onError: (mutationError, variables) => {
|
||||||
if (mutationError instanceof ApiError) {
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
|
setSubmitError(
|
||||||
|
errorMessage(
|
||||||
|
mutationError,
|
||||||
|
"Schwarmkontrolle konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
|
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}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -78,21 +131,25 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!colonyId) {
|
const errors = validate(values);
|
||||||
setError("Bitte ein Volk auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutation.mutate({
|
mutation.mutate(toSwarmControlInput(values));
|
||||||
colonyId,
|
}
|
||||||
date: new Date(date).toISOString(),
|
|
||||||
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
|
function restoreDraft() {
|
||||||
queenCellsCapped,
|
const restored = draft.restore();
|
||||||
swarmMood,
|
if (restored) {
|
||||||
actionTaken,
|
setValues(restored);
|
||||||
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
|
setDirty(true);
|
||||||
notes: notes || undefined,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,17 +157,53 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Sprout}
|
icon={Sprout}
|
||||||
title="Neue Schwarmkontrolle"
|
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)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="swarm-colony">Volk</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
noValidate
|
||||||
<SelectTrigger id="swarm-colony" className="h-11 w-full">
|
>
|
||||||
<SelectValue placeholder="Volk wählen" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{activeColonies.map((colony) => (
|
{activeColonies.map((colony) => (
|
||||||
@@ -120,104 +213,168 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="swarm-date">Datum</Label>
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
|
{({ id, describedBy, invalid }) => (
|
||||||
<Input
|
<Input
|
||||||
id="swarm-date"
|
id={id}
|
||||||
|
data-field="date"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={date}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setDate(event.target.value)}
|
aria-invalid={invalid}
|
||||||
|
value={values.date}
|
||||||
|
onChange={(event) => set("date", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="swarm-cells-found">Weiselzellen gefunden</Label>
|
</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
|
<Input
|
||||||
id="swarm-cells-found"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={queenCellsFound}
|
value={values.queenCellsFound}
|
||||||
onChange={(event) => setQueenCellsFound(event.target.value)}
|
onChange={(event) =>
|
||||||
/>
|
set("queenCellsFound", 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)
|
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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">
|
{({ id, labelId, describedBy }) => (
|
||||||
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
|
<div
|
||||||
</SelectTrigger>
|
id={id}
|
||||||
<SelectContent>
|
role="radiogroup"
|
||||||
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
|
aria-labelledby={labelId}
|
||||||
<SelectItem key={option} value={option}>
|
aria-describedby={describedBy}
|
||||||
{SWARM_ACTION_LABELS[option]}
|
className="grid grid-cols-3 gap-2"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
{SWARM_MOODS.map((option) => {
|
||||||
</SelectContent>
|
const selected = values.swarmMood === option;
|
||||||
</Select>
|
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>
|
||||||
<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
|
<Input
|
||||||
id="swarm-next-check"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="date"
|
type="date"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={nextCheckDate}
|
value={values.nextCheckDate}
|
||||||
onChange={(event) => setNextCheckDate(event.target.value)}
|
onChange={(event) =>
|
||||||
|
set("nextCheckDate", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="swarm-notes">Notizen</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
step={4}
|
||||||
|
title="Weitere Angaben"
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
fieldCount={1}
|
||||||
|
>
|
||||||
|
<Field label="Notizen">
|
||||||
|
{({ id }) => (
|
||||||
<Textarea
|
<Textarea
|
||||||
id="swarm-notes"
|
id={id}
|
||||||
value={notes}
|
value={values.notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => set("notes", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
}
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Kontrolle speichern"}
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending
|
||||||
|
? "Wird gespeichert…"
|
||||||
|
: "Kontrolle speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,12 +6,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ListTodo } from "lucide-react";
|
import { ListTodo } from "lucide-react";
|
||||||
import { createTask } from "@/lib/tasks/api";
|
import { createTask } from "@/lib/tasks/api";
|
||||||
import { TaskPriority } from "@/lib/tasks/types";
|
|
||||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||||
import { fetchUsers } from "@/lib/users/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -22,16 +23,36 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||||
|
import { DraftBanner } from "@/components/form/draft-banner";
|
||||||
|
import {
|
||||||
|
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() {
|
export default function NewTaskPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
const [values, setValues] = useState<TaskFormValues>(initialTaskValues);
|
||||||
const [priority, setPriority] = useState<TaskPriority>("NORMAL");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [dueDate, setDueDate] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [assignedUserId, setAssignedUserId] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const draft = useFormDraft("task-new", values, dirty);
|
||||||
|
|
||||||
// Assigning a task requires users:read, which not every role has - if the
|
// Assigning a task requires users:read, which not every role has - if the
|
||||||
// request fails, the assignee field below is simply omitted.
|
// request fails, the assignee field below is simply omitted.
|
||||||
@@ -41,28 +62,53 @@ export default function NewTaskPage() {
|
|||||||
retry: false,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createTask,
|
mutationFn: createTask,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
toast.success("Aufgabe angelegt");
|
toast.success("Aufgabe angelegt");
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError) => {
|
||||||
setError("Aufgabe konnte nicht angelegt werden.");
|
setSubmitError(
|
||||||
|
errorMessage(mutationError, "Aufgabe konnte nicht angelegt werden."),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
mutation.mutate({
|
const errors = validate(values);
|
||||||
title,
|
setFieldErrors(errors);
|
||||||
description: description || undefined,
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
priority,
|
if (firstInvalid) {
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
document
|
||||||
assignedUserId: assignedUserId || undefined,
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
});
|
?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutation.mutate(toTaskInput(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,53 +116,112 @@ export default function NewTaskPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={ListTodo}
|
icon={ListTodo}
|
||||||
title="Neue Aufgabe"
|
title="Neue Aufgabe"
|
||||||
description="Lege eine neue Aufgabe an."
|
description="Nur der Titel ist Pflicht."
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="task-title">Titel</Label>
|
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
|
<Input
|
||||||
id="task-title"
|
id={id}
|
||||||
|
data-field="title"
|
||||||
required
|
required
|
||||||
value={title}
|
className="h-11"
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
value={values.title}
|
||||||
|
onChange={(event) => set("title", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="task-priority">Priorität</Label>
|
<Field label="Priorität">
|
||||||
<Select
|
{({ id, labelId }) => (
|
||||||
value={priority}
|
<div
|
||||||
onValueChange={(value) => setPriority((value ?? "NORMAL") as TaskPriority)}
|
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]}
|
{TASK_PRIORITY_LABELS[option]}
|
||||||
</SelectItem>
|
</button>
|
||||||
))}
|
);
|
||||||
</SelectContent>
|
})}
|
||||||
</Select>
|
|
||||||
</div>
|
</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 ? (
|
{users ? (
|
||||||
<div className="flex flex-col gap-2">
|
<Field label="Zugewiesen an">
|
||||||
<Label htmlFor="task-assignee">Zugewiesen an</Label>
|
{({ id }) => (
|
||||||
<Select
|
<Select
|
||||||
value={assignedUserId || "NONE"}
|
value={values.assignedUserId || "NONE"}
|
||||||
onValueChange={(value) =>
|
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">
|
<SelectValue placeholder="Niemand zugewiesen">
|
||||||
{assignedUserId
|
{values.assignedUserId
|
||||||
? (users.find((user) => user.id === assignedUserId)?.name ??
|
? (users.find(
|
||||||
"Niemand zugewiesen")
|
(user) => user.id === values.assignedUserId,
|
||||||
|
)?.name ?? "Niemand zugewiesen")
|
||||||
: "Niemand zugewiesen"}
|
: "Niemand zugewiesen"}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -129,33 +234,40 @@ export default function NewTaskPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-col gap-2">
|
<Field label="Beschreibung">
|
||||||
<Label htmlFor="task-due-date">Fällig am</Label>
|
{({ id }) => (
|
||||||
<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>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="task-description"
|
id={id}
|
||||||
value={description}
|
value={values.description}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event) => set("description", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
}
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Wird angelegt…" : "Aufgabe anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Bug } from "lucide-react";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { createVarroaMeasurement } from "@/lib/varroa/api";
|
import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||||
import { VarroaMethod } from "@/lib/varroa/types";
|
|
||||||
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueVarroaMeasurement } from "@/lib/offline/db";
|
import { queueVarroaMeasurement } from "@/lib/offline/db";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
|
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||||
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,9 +26,27 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field } from "@/components/form/field";
|
||||||
|
import { FormSection } from "@/components/form/form-section";
|
||||||
|
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||||
|
import { DraftBanner } from "@/components/form/draft-banner";
|
||||||
|
import {
|
||||||
|
VARROA_METHOD_HINTS,
|
||||||
|
VarroaFormValues,
|
||||||
|
initialVarroaValues,
|
||||||
|
toVarroaInput,
|
||||||
|
} from "./varroa-form-values";
|
||||||
|
|
||||||
function todayIsoDate(): string {
|
type RequiredKey = "colonyId" | "date";
|
||||||
return new Date().toISOString().slice(0, 10);
|
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() {
|
export default function NewVarroaMeasurementPage() {
|
||||||
@@ -35,20 +54,31 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: colonies } = useQuery({
|
const { data: colonies, isLoading: coloniesLoading } = useQuery({
|
||||||
queryKey: ["colonies"],
|
queryKey: ["colonies"],
|
||||||
queryFn: () => fetchColonies(),
|
queryFn: () => fetchColonies(),
|
||||||
});
|
});
|
||||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
const activeColonies =
|
||||||
|
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||||
|
|
||||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
const [values, setValues] = useState<VarroaFormValues>(() =>
|
||||||
const [date, setDate] = useState(todayIsoDate());
|
initialVarroaValues(searchParams.get("colonyId") ?? ""),
|
||||||
const [method, setMethod] = useState<VarroaMethod>("NATURAL_MITE_FALL");
|
);
|
||||||
const [miteCount, setMiteCount] = useState("");
|
const [dirty, setDirty] = useState(false);
|
||||||
const [durationMinutes, setDurationMinutes] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [assessment, setAssessment] = useState("");
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const draft = useFormDraft("varroa-new", values, dirty);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
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
|
// One key per opened form: a double tap or an offline retry of the
|
||||||
// same entry is recognised by the server instead of saved twice.
|
// same entry is recognised by the server instead of saved twice.
|
||||||
@@ -57,6 +87,7 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
|
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
|
||||||
createVarroaMeasurement(input, { idempotencyKey }),
|
createVarroaMeasurement(input, { idempotencyKey }),
|
||||||
onSuccess: (measurement) => {
|
onSuccess: (measurement) => {
|
||||||
|
draft.clear();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
||||||
});
|
});
|
||||||
@@ -65,11 +96,19 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
},
|
},
|
||||||
onError: (mutationError, variables) => {
|
onError: (mutationError, variables) => {
|
||||||
if (mutationError instanceof ApiError) {
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Varroamessung konnte nicht gespeichert werden.");
|
setSubmitError(
|
||||||
|
errorMessage(
|
||||||
|
mutationError,
|
||||||
|
"Varroamessung konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
|
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}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -77,38 +116,81 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
if (!colonyId) {
|
const errors = validate(values);
|
||||||
setError("Bitte ein Volk auswählen.");
|
setFieldErrors(errors);
|
||||||
|
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||||
|
?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutation.mutate({
|
mutation.mutate(toVarroaInput(values));
|
||||||
colonyId,
|
|
||||||
date: new Date(date).toISOString(),
|
|
||||||
method,
|
|
||||||
miteCount: miteCount ? Number(miteCount) : undefined,
|
|
||||||
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
|
|
||||||
assessment: assessment || undefined,
|
|
||||||
notes: notes || undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const restored = draft.restore();
|
||||||
|
if (restored) {
|
||||||
|
setValues(restored);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNaturalFall = values.method === "NATURAL_MITE_FALL";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Bug}
|
icon={Bug}
|
||||||
title="Neue Varroamessung"
|
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)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
{draft.pending ? (
|
||||||
|
<DraftBanner
|
||||||
|
savedAt={draft.pending.savedAt}
|
||||||
|
onRestore={restoreDraft}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form
|
||||||
<div className="flex flex-col gap-2">
|
className="flex flex-col gap-6"
|
||||||
<Label htmlFor="varroa-colony">Volk</Label>
|
onSubmit={handleSubmit}
|
||||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
noValidate
|
||||||
<SelectTrigger id="varroa-colony" className="h-11 w-full">
|
>
|
||||||
<SelectValue placeholder="Volk wählen" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{activeColonies.map((colony) => (
|
{activeColonies.map((colony) => (
|
||||||
@@ -118,85 +200,156 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="varroa-date">Datum</Label>
|
<Field label="Datum" required error={fieldErrors.date}>
|
||||||
|
{({ id, describedBy, invalid }) => (
|
||||||
<Input
|
<Input
|
||||||
id="varroa-date"
|
id={id}
|
||||||
|
data-field="date"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={date}
|
aria-describedby={describedBy}
|
||||||
onChange={(event) => setDate(event.target.value)}
|
aria-invalid={invalid}
|
||||||
|
value={values.date}
|
||||||
|
onChange={(event) => set("date", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="varroa-method">Methode</Label>
|
</FormSection>
|
||||||
<Select
|
|
||||||
value={method}
|
<FormSection step={2} title="Wie gemessen?">
|
||||||
onValueChange={(value) => setMethod((value ?? "NATURAL_MITE_FALL") as VarroaMethod)}
|
<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]}
|
{VARROA_METHOD_LABELS[option]}
|
||||||
</SelectItem>
|
</button>
|
||||||
))}
|
);
|
||||||
</SelectContent>
|
})}
|
||||||
</Select>
|
|
||||||
</div>
|
</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
|
<Input
|
||||||
id="varroa-mite-count"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={miteCount}
|
value={values.miteCount}
|
||||||
onChange={(event) => setMiteCount(event.target.value)}
|
onChange={(event) => set("miteCount", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="varroa-duration">Zeitraum (Minuten)</Label>
|
<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
|
<Input
|
||||||
id="varroa-duration"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={durationMinutes}
|
value={values.durationMinutes}
|
||||||
onChange={(event) => setDurationMinutes(event.target.value)}
|
onChange={(event) =>
|
||||||
|
set("durationMinutes", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="varroa-assessment">Einschätzung</Label>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
step={4}
|
||||||
|
title="Weitere Angaben"
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
fieldCount={2}
|
||||||
|
>
|
||||||
|
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
id="varroa-assessment"
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
value={assessment}
|
value={values.assessment}
|
||||||
onChange={(event) => setAssessment(event.target.value)}
|
onChange={(event) => set("assessment", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="varroa-notes">Notizen</Label>
|
<Field label="Notizen">
|
||||||
|
{({ id }) => (
|
||||||
<Textarea
|
<Textarea
|
||||||
id="varroa-notes"
|
id={id}
|
||||||
value={notes}
|
value={values.notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => set("notes", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{error ? (
|
</Field>
|
||||||
<p role="alert" className="text-sm text-destructive">
|
</FormSection>
|
||||||
{error}
|
|
||||||
|
<StickySubmit
|
||||||
|
note={
|
||||||
|
submitError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm font-medium text-destructive"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null
|
||||||
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 text-base"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Messung speichern"}
|
{mutation.isPending ? "Wird gespeichert…" : "Messung speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</StickySubmit>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user