feat(ui): colony edit dialogs with sections and tap ratings, confirmed merge

Inspection, treatment, feeding, varroa and swarm-control edit dialogs use
the form kit (required markers, inline errors, 1-5 as buttons, yes/no
tiles, number keyboards, save button pinned at the dialog bottom).
Moving, splitting and merging colonies report success; merging - which
cannot be undone - asks first and says what happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 12:14:07 +02:00
1 parent 9944aabbc3
commit a21b1665e7
9 files changed
+1147 -604

No files matched your search

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyActions } from "./colony-actions";
import { fetchColonies, mergeColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({
fetchColonies: vi.fn(),
mergeColony: vi.fn(),
moveColony: vi.fn(),
splitColony: vi.fn(),
dissolveColony: vi.fn(),
reactivateColony: vi.fn(),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/queens/api", () => ({
fetchQueens: vi.fn().mockResolvedValue([]),
assignQueenColony: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function colony(id: string, code: string): Colony {
return {
id,
code,
apiaryId: "apiary-1",
hiveType: null,
originText: null,
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,
notes: null,
currentQueenId: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
};
}
const target = colony("colony-1", "V-01");
const other = colony("colony-2", "V-02");
function renderActions() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ColonyActions colony={target} />
</QueryClientProvider>,
);
}
describe("ColonyActions – merge", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([target, other]);
vi.mocked(mergeColony).mockReset();
vi.mocked(mergeColony).mockResolvedValue(target);
});
it("asks for confirmation in plain words before merging", async () => {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Vereinigen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "V-02" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" }));
expect(
await screen.findByText(
"Volk V-02 geht in Volk V-01 auf und wird beendet. Das lässt sich nicht rückgängig machen.",
),
).toBeInTheDocument();
expect(mergeColony).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "V-02 in V-01 aufgehen lassen" }),
);
expect(mergeColony).toHaveBeenCalledWith("colony-1", {
mergedColonyIds: ["colony-2"],
});
});
it("does not merge when the confirmation is cancelled", async () => {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Vereinigen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "V-02" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" }));
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
expect(mergeColony).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,8 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, ReactNode, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import { import {
dissolveColony, dissolveColony,
mergeColony, mergeColony,
@@ -16,13 +15,15 @@ import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { assignQueenColony, fetchQueens } from "@/lib/queens/api"; import { assignQueenColony, fetchQueens } from "@/lib/queens/api";
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 { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -36,10 +37,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function invalidateColony( function invalidateColony(
queryClient: ReturnType<typeof useQueryClient>, queryClient: ReturnType<typeof useQueryClient>,
colonyId: string, colonyId: string,
@@ -49,23 +46,49 @@ function invalidateColony(
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] }); void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] });
} }
/** A failed action explains itself right above the button that caused it. */
function ActionFooter({
error,
children,
}: {
error: string | null;
children: ReactNode;
}) {
return (
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
{children}
</DialogFooter>
);
}
function MoveDialog({ colony }: { colony: Colony }) { function MoveDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState(""); const [apiaryId, setApiaryId] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: apiaries } = useQuery({ const { data: apiaries } = useQuery({
queryKey: ["apiaries", { status: "ACTIVE" }], queryKey: ["apiaries", { status: "ACTIVE" }],
queryFn: () => fetchApiaries({ status: "ACTIVE" }), queryFn: () => fetchApiaries({ status: "ACTIVE" }),
}); });
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId }), mutationFn: () => moveColony(colony.id, { apiaryId }),
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
const target = targets.find((apiary) => apiary.id === apiaryId);
toast.success(
target ? `${colony.code} steht jetzt am Stand „${target.name}“` : "Volk umgesetzt",
);
setOpen(false); setOpen(false);
}, },
onError: (error) => onError: (mutationError) =>
toast.error(errorMessage(error, "Volk konnte nicht umgesetzt werden")), setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")),
}); });
return ( return (
@@ -80,36 +103,40 @@ function MoveDialog({ colony }: { colony: Colony }) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Volk umsetzen</DialogTitle> <DialogTitle>Volk umsetzen</DialogTitle>
<DialogDescription>
{colony.code} zieht an einen anderen Bienenstand. Der Wechsel wird in der Historie
festgehalten.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => { onSubmit={(event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null);
mutation.mutate(); mutation.mutate();
}} }}
> >
<div className="flex flex-col gap-2"> <Field label="Neuer Bienenstand" required>
<Label htmlFor="move-apiary">Neuer Bienenstand</Label> {({ id }) => (
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}> <Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}>
<SelectTrigger id="move-apiary"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Bienenstand wählen" /> <SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(apiaries ?? []) {targets.map((apiary) => (
.filter((apiary) => apiary.id !== colony.apiaryId)
.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}> <SelectItem key={apiary.id} value={apiary.id}>
{apiary.name} {apiary.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<DialogFooter> </Field>
<Button type="submit" disabled={!apiaryId || mutation.isPending}> <ActionFooter error={error}>
Umsetzen <Button type="submit" size="touch" disabled={!apiaryId || mutation.isPending}>
{mutation.isPending ? "Wird umgesetzt…" : "Umsetzen"}
</Button> </Button>
</DialogFooter> </ActionFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -121,18 +148,31 @@ function SplitDialog({ colony }: { colony: Colony }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY"); const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY");
const [codeError, setCodeError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => splitColony(colony.id, { code, colonyType }), mutationFn: () => splitColony(colony.id, { code, colonyType }),
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
toast.success(`Ableger ${code} angelegt`);
setOpen(false); setOpen(false);
}, },
onError: (error) => onError: (mutationError) =>
toast.error(errorMessage(error, "Ableger konnte nicht angelegt werden")), setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")),
}); });
function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
if (!code.trim()) {
setCodeError("Bitte einen Code für den Ableger angeben.");
return;
}
mutation.mutate();
}
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
@@ -145,30 +185,34 @@ function SplitDialog({ colony }: { colony: Colony }) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Ableger bilden</DialogTitle> <DialogTitle>Ableger bilden</DialogTitle>
<DialogDescription>
Aus {colony.code} entsteht ein neues Volk. {colony.code} bleibt bestehen.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
className="flex flex-col gap-4" <Field label="Code des Ablegers" required error={codeError}>
onSubmit={(event: FormEvent) => { {({ id, describedBy, invalid }) => (
event.preventDefault();
mutation.mutate();
}}
>
<div className="flex flex-col gap-2">
<Label htmlFor="split-code">Code des Ablegers</Label>
<Input <Input
id="split-code" id={id}
required required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={code} value={code}
onChange={(event) => setCode(event.target.value)} onChange={(event) => {
setCode(event.target.value);
setCodeError(null);
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="split-type">Volkstyp</Label> <Field label="Volkstyp" required>
{({ id }) => (
<Select <Select
value={colonyType} value={colonyType}
onValueChange={(value) => setColonyType(value as ColonyType)} onValueChange={(value) => setColonyType(value as ColonyType)}
> >
<SelectTrigger id="split-type"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue> <SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -179,12 +223,13 @@ function SplitDialog({ colony }: { colony: Colony }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<DialogFooter> </Field>
<Button type="submit" disabled={mutation.isPending}> <ActionFooter error={error}>
Ableger anlegen <Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"}
</Button> </Button>
</DialogFooter> </ActionFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -195,6 +240,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [queenId, setQueenId] = useState(""); const [queenId, setQueenId] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: queens } = useQuery({ queryKey: ["queens"], queryFn: () => fetchQueens() }); const { data: queens } = useQuery({ queryKey: ["queens"], queryFn: () => fetchQueens() });
const currentQueen = queens?.find((queen) => queen.id === colony.currentQueenId); const currentQueen = queens?.find((queen) => queen.id === colony.currentQueenId);
@@ -205,8 +251,8 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["queens"] }); void queryClient.invalidateQueries({ queryKey: ["queens"] });
setOpen(false); setOpen(false);
}, },
onError: (error) => onError: (mutationError) =>
toast.error(errorMessage(error, "Königin konnte nicht zugewiesen werden")), setError(errorMessage(mutationError, "Königin konnte nicht zugewiesen werden.")),
}); });
return ( return (
@@ -226,13 +272,14 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => { onSubmit={(event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null);
mutation.mutate(); mutation.mutate();
}} }}
> >
<div className="flex flex-col gap-2"> <Field label="Königin" required>
<Label htmlFor="assign-queen">Königin</Label> {({ id }) => (
<Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}> <Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}>
<SelectTrigger id="assign-queen"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Königin wählen" /> <SelectValue placeholder="Königin wählen" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -245,33 +292,46 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<DialogFooter> </Field>
<Button type="submit" disabled={!queenId || mutation.isPending}> <ActionFooter error={error}>
<Button type="submit" size="touch" disabled={!queenId || mutation.isPending}>
Zuweisen Zuweisen
</Button> </Button>
</DialogFooter> </ActionFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
/**
* Merging ends the other colony for good, so the button in the dialog does
* not merge yet - it asks once more, in plain words, which colony goes away.
*/
function MergeDialog({ colony }: { colony: Colony }) { function MergeDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mergedColonyId, setMergedColonyId] = useState(""); const [mergedColonyId, setMergedColonyId] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const candidates = (colonies ?? []).filter(
(candidate) => candidate.id !== colony.id && candidate.status === "ACTIVE",
);
const merged = candidates.find((candidate) => candidate.id === mergedColonyId);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }), mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }),
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
toast.success(
merged ? `${merged.code} ist jetzt Teil von ${colony.code}` : "Völker vereinigt",
);
setOpen(false); setOpen(false);
}, },
onError: (error) => onError: (mutationError) =>
toast.error(errorMessage(error, "Völker konnten nicht vereinigt werden")), setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")),
}); });
return ( return (
@@ -286,40 +346,50 @@ function MergeDialog({ colony }: { colony: Colony }) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Völker vereinigen</DialogTitle> <DialogTitle>Völker vereinigen</DialogTitle>
<DialogDescription>
Das gewählte Volk geht in {colony.code} auf.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form <div className="flex flex-col gap-4">
className="flex flex-col gap-4" <Field label="Welches Volk kommt dazu?" required>
onSubmit={(event: FormEvent) => { {({ id }) => (
event.preventDefault();
mutation.mutate();
}}
>
<div className="flex flex-col gap-2">
<Label htmlFor="merge-colony">Zu vereinigendes Volk</Label>
<Select <Select
value={mergedColonyId} value={mergedColonyId}
onValueChange={(value) => setMergedColonyId(value ?? "")} onValueChange={(value) => {
setMergedColonyId(value ?? "");
setError(null);
}}
> >
<SelectTrigger id="merge-colony"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Volk wählen" /> <SelectValue placeholder="Volk wählen" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(colonies ?? []) {candidates.map((candidate) => (
.filter((c) => c.id !== colony.id && c.status === "ACTIVE") <SelectItem key={candidate.id} value={candidate.id}>
.map((c) => ( {candidate.code}
<SelectItem key={c.id} value={c.id}>
{c.code}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
</Field>
<ActionFooter error={error}>
<ConfirmDialog
triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"}
triggerVariant="default"
triggerSize="touch"
triggerDisabled={!merged || mutation.isPending}
title="Völker wirklich vereinigen?"
description={`Volk ${merged?.code ?? ""} geht in Volk ${colony.code} auf und wird beendet. Das lässt sich nicht rückgängig machen.`}
confirmLabel={`${merged?.code ?? "Volk"} in ${colony.code} aufgehen lassen`}
isPending={mutation.isPending}
onConfirm={() => {
setError(null);
mutation.mutate();
}}
/>
</ActionFooter>
</div> </div>
<DialogFooter>
<Button type="submit" disabled={!mergedColonyId || mutation.isPending}>
Vereinigen
</Button>
</DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -335,7 +405,8 @@ function DissolveButton({ colony }: { colony: Colony }) {
description: "Das Volk kann jederzeit über „Reaktivieren“ wiederhergestellt werden.", description: "Das Volk kann jederzeit über „Reaktivieren“ wiederhergestellt werden.",
}); });
}, },
onError: () => toast.error("Volk konnte nicht aufgelöst werden"), onError: (error) =>
toast.error(errorMessage(error, "Volk konnte nicht aufgelöst werden.")),
}); });
return ( return (
@@ -359,12 +430,14 @@ function ReactivateButton({ colony }: { colony: Colony }) {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
toast.success("Volk reaktiviert"); toast.success("Volk reaktiviert");
}, },
onError: () => toast.error("Volk konnte nicht reaktiviert werden"), onError: (error) =>
toast.error(errorMessage(error, "Volk konnte nicht reaktiviert werden.")),
}); });
return ( return (
<Button <Button
type="button" type="button"
size="touch"
disabled={mutation.isPending} disabled={mutation.isPending}
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
> >
@@ -0,0 +1,55 @@
"use client";
import { ComponentProps } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
* a phone, so it can be hit with a gloved thumb.
*/
export function EditTriggerButton(props: ComponentProps<typeof Button>) {
return (
<Button variant="ghost" {...props}>
<Pencil className="size-4" aria-hidden />
Bearbeiten
</Button>
);
}
interface DialogSubmitBarProps {
isPending: boolean;
error?: string | null;
label?: string;
}
/**
* Save button pinned to the bottom of a scrolling dialog: however long the
* form, the one action that finishes it never scrolls out of sight. A server
* error appears right above it, where the eyes already are.
*/
export function DialogSubmitBar({
isPending,
error,
label = "Speichern",
}: DialogSubmitBarProps) {
return (
<div className="sticky bottom-0 z-10 -mx-5 -mb-5 flex flex-col gap-2 rounded-b-2xl border-t border-border/60 bg-popover px-5 py-3">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={isPending}>
{isPending ? "Wird gespeichert…" : label}
</Button>
</div>
);
}
export const DATE_REQUIRED_MESSAGE = "Bitte ein Datum angeben.";
/** True when at least one of the optional values already holds something. */
export function hasAnyValue(...values: (string | null | undefined)[]): boolean {
return values.some((value) => Boolean(value && value.trim()));
}
@@ -1,15 +1,12 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateFeeding } from "@/lib/feedings/api"; import { updateFeeding } from "@/lib/feedings/api";
import { Feeding, FeedType } from "@/lib/feedings/types"; import { Feeding, FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { Button } from "@/components/ui/button"; import { errorMessage } from "@/lib/forms/error-message";
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,
@@ -19,6 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,6 +25,18 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
interface FieldErrors {
date?: string;
amount?: string;
unit?: string;
}
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) { export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -38,6 +49,8 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const [unit, setUnit] = useState(feeding.unit); const [unit, setUnit] = useState(feeding.unit);
const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? ""); const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? "");
const [notes, setNotes] = useState(feeding.notes ?? ""); const [notes, setNotes] = useState(feeding.notes ?? "");
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -61,50 +74,66 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
} }
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Fütterung konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
const errors: FieldErrors = {
date: date ? undefined : DATE_REQUIRED_MESSAGE,
amount: amount.trim() ? undefined : "Bitte die Menge angeben.",
unit: unit.trim() ? undefined : "Bitte die Einheit angeben, z. B. kg oder l.",
};
setFieldErrors(errors);
if (errors.date || errors.amount || errors.unit) {
return;
}
mutation.mutate(); mutation.mutate();
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Fütterung bearbeiten</DialogTitle> <DialogTitle>Fütterung bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection step={1} title="Welches Volk, wann?">
<ColonySelectField <ColonySelectField
id="feeding-edit-colony" id="feeding-edit-colony"
value={colonyId} value={colonyId}
onChange={setColonyId} onChange={setColonyId}
/> />
<div className="flex flex-col gap-2"> <Field label="Datum" required error={fieldErrors.date}>
<Label htmlFor="feeding-edit-date">Datum</Label> {({ id, describedBy, invalid }) => (
<Input <Input
id="feeding-edit-date" id={id}
type="date" type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date} value={date}
onChange={(event) => setDate(event.target.value)} onChange={(event) => {
setDate(event.target.value);
setFieldErrors((current) => ({ ...current, date: undefined }));
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-edit-type">Futterart</Label> </FormSection>
<FormSection step={2} title="Was und wie viel?">
<Field label="Futterart" required>
{({ id }) => (
<Select <Select
value={feedType} value={feedType}
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)} onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
> >
<SelectTrigger id="feeding-edit-type" className="w-full"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue> <SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -115,54 +144,80 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex gap-3"> </Field>
<div className="flex flex-1 flex-col gap-2"> <div className="grid grid-cols-2 gap-3">
<Label htmlFor="feeding-edit-amount">Menge</Label> <Field label="Menge" required error={fieldErrors.amount}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="feeding-edit-amount" id={id}
type="number" type="number"
inputMode="decimal"
min={0} min={0}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={amount} value={amount}
onChange={(event) => setAmount(event.target.value)} onChange={(event) => {
setAmount(event.target.value);
setFieldErrors((current) => ({ ...current, amount: undefined }));
}}
/> />
</div> )}
<div className="flex flex-1 flex-col gap-2"> </Field>
<Label htmlFor="feeding-edit-unit">Einheit</Label> <Field label="Einheit" required error={fieldErrors.unit}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="feeding-edit-unit" id={id}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={unit} value={unit}
onChange={(event) => setUnit(event.target.value)} onChange={(event) => {
setUnit(event.target.value);
setFieldErrors((current) => ({ ...current, unit: undefined }));
}}
/> />
)}
</Field>
</div> </div>
</div> <Field label="Produkt">
<div className="flex flex-col gap-2"> {({ id }) => (
<Label htmlFor="feeding-edit-product">Produkt</Label>
<Input <Input
id="feeding-edit-product" id={id}
className="h-11"
value={product} value={product}
onChange={(event) => setProduct(event.target.value)} onChange={(event) => setProduct(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-edit-batch">Chargennummer</Label> </FormSection>
<FormSection
step={3}
title="Weitere Angaben"
collapsible
defaultOpen={hasAnyValue(batchNumber, notes)}
fieldCount={2}
>
<Field label="Chargennummer">
{({ id }) => (
<Input <Input
id="feeding-edit-batch" id={id}
className="h-11"
value={batchNumber} value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)} onChange={(event) => setBatchNumber(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-edit-notes">Notizen</Label> <Field label="Notizen">
<Textarea {({ id }) => (
id="feeding-edit-notes" <Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
value={notes} )}
onChange={(event) => setNotes(event.target.value)} </Field>
/> </FormSection>
</div>
<Button type="submit" disabled={mutation.isPending}> <DialogSubmitBar isPending={mutation.isPending} error={submitError} />
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -0,0 +1,106 @@
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 { InspectionEditDialog } from "./inspection-edit-dialog";
import { updateInspection } from "@/lib/inspections/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import type { Inspection } from "@/lib/inspections/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ updateInspection: vi.fn() }));
const inspection: Inspection = {
id: "i-1",
colonyId: "colony-1",
userId: "u-1",
date: "2026-06-01T00:00:00.000Z",
weather: null,
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: true,
eggsPresent: true,
queenSeen: false,
queenCellsPresent: false,
swarmMood: null,
gentleness: 2,
combBehavior: null,
feedStatus: "ausreichend",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
measures: null,
notes: null,
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<InspectionEditDialog inspection={inspection} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
return screen.findByRole("dialog");
}
describe("InspectionEditDialog", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(updateInspection).mockReset();
});
it("sends the tapped rating as a number", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
// Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter.
const gentleness = screen.getAllByRole("radiogroup")[1];
await userEvent.click(
gentleness.querySelector('[aria-label="5 von 5"]') as HTMLElement,
);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [id, input] = vi.mocked(updateInspection).mock.calls[0];
expect(id).toBe("i-1");
expect(input).toMatchObject({
colonyId: "colony-1",
gentleness: 5,
broodPattern: 3,
colonyStrength: 8,
});
});
it("shows a missing date next to the field and does not save", async () => {
await openDialog();
await userEvent.clear(screen.getByLabelText(/Datum/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/Datum/)).toHaveAttribute("aria-invalid", "true");
expect(updateInspection).not.toHaveBeenCalled();
});
it("shows the server's reason inline when saving fails", async () => {
vi.mocked(updateInspection).mockRejectedValue(
new ApiError(400, "Datum liegt in der Zukunft"),
);
await openDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(
await screen.findByText("Datum liegt in der Zukunft"),
).toBeInTheDocument();
});
});
@@ -1,29 +1,17 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateInspection } from "@/lib/inspections/api"; import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types"; import { Inspection } from "@/lib/inspections/types";
import { errorMessage } from "@/lib/forms/error-message";
const FEED_STATUS_LABELS: Record<string, string> = {
ausreichend: "Ausreichend",
niedrig: "Niedrig",
kritisch: "Kritisch",
};
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ToggleChip } from "@/components/form/toggle-chip";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -31,304 +19,335 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
FEED_STATUS_OPTIONS,
FeedStatus,
InspectionFormValues,
toInspectionInput,
} from "../../inspections/new/inspection-form-values";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
function valuesFromInspection(inspection: Inspection): InspectionFormValues {
return {
colonyId: inspection.colonyId,
date: inspection.date.slice(0, 10),
weather: inspection.weather ?? "",
temperature: inspection.temperature?.toString() ?? "",
colonyStrength: inspection.colonyStrength?.toString() ?? "",
broodAmount: inspection.broodAmount ?? "",
broodPattern: inspection.broodPattern,
openBroodPresent: inspection.openBroodPresent ?? false,
cappedBroodPresent: inspection.cappedBroodPresent ?? false,
queenSeen: inspection.queenSeen ?? false,
eggsPresent: inspection.eggsPresent ?? true,
queenCellsPresent: inspection.queenCellsPresent ?? false,
swarmMood: inspection.swarmMood,
gentleness: inspection.gentleness,
combBehavior: inspection.combBehavior,
// Kept verbatim even if it is not one of the three known values, so
// saving without touching it never changes it.
feedStatus: (inspection.feedStatus ?? "ausreichend") as FeedStatus,
honeySuperStatus: inspection.honeySuperStatus ?? "",
healthIssues: inspection.healthIssues ?? "",
varroaIssues: inspection.varroaIssues ?? "",
measures: inspection.measures ?? "",
notes: inspection.notes ?? "",
};
}
const RATINGS: {
key: "gentleness" | "combBehavior" | "swarmMood";
label: string;
low: string;
high: string;
}[] = [
{ key: "gentleness", label: "Sanftmut", low: "stechlustig", high: "sehr sanft" },
{ key: "combBehavior", label: "Wabensitz", low: "läuft weg", high: "sitzt ruhig" },
{ key: "swarmMood", label: "Schwarmstimmung", low: "keine", high: "stark" },
];
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) { export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(inspection.colonyId); const [values, setValues] = useState<InspectionFormValues>(() =>
const [date, setDate] = useState(inspection.date.slice(0, 10)); valuesFromInspection(inspection),
const [weather, setWeather] = useState(inspection.weather ?? "");
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
const [colonyStrength, setColonyStrength] = useState(
inspection.colonyStrength?.toString() ?? "",
); );
const [broodAmount, setBroodAmount] = useState(inspection.broodAmount ?? ""); const [dateError, setDateError] = useState<string | null>(null);
const [broodPattern, setBroodPattern] = useState(inspection.broodPattern?.toString() ?? ""); const [submitError, setSubmitError] = useState<string | null>(null);
const [openBroodPresent, setOpenBroodPresent] = useState(inspection.openBroodPresent ?? false);
const [cappedBroodPresent, setCappedBroodPresent] = useState( function set<K extends keyof InspectionFormValues>(
inspection.cappedBroodPresent ?? false, key: K,
); value: InspectionFormValues[K],
const [queenSeen, setQueenSeen] = useState(inspection.queenSeen ?? false); ) {
const [eggsPresent, setEggsPresent] = useState(inspection.eggsPresent ?? true); setValues((current) => ({ ...current, [key]: value }));
const [queenCellsPresent, setQueenCellsPresent] = useState( if (key === "date") setDateError(null);
inspection.queenCellsPresent ?? false, }
);
const [swarmMood, setSwarmMood] = useState(inspection.swarmMood?.toString() ?? "");
const [gentleness, setGentleness] = useState(inspection.gentleness?.toString() ?? "");
const [combBehavior, setCombBehavior] = useState(inspection.combBehavior?.toString() ?? "");
const [feedStatus, setFeedStatus] = useState(inspection.feedStatus ?? "ausreichend");
const [honeySuperStatus, setHoneySuperStatus] = useState(inspection.honeySuperStatus ?? "");
const [healthIssues, setHealthIssues] = useState(inspection.healthIssues ?? "");
const [varroaIssues, setVarroaIssues] = useState(inspection.varroaIssues ?? "");
const [measures, setMeasures] = useState(inspection.measures ?? "");
const [notes, setNotes] = useState(inspection.notes ?? "");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: (input: InspectionFormValues) =>
updateInspection(inspection.id, { updateInspection(inspection.id, toInspectionInput(input)),
colonyId, onSuccess: (_result, input) => {
date: new Date(date).toISOString(),
weather: weather || undefined,
temperature: temperature ? Number(temperature) : undefined,
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
broodAmount: broodAmount || undefined,
broodPattern: broodPattern ? Number(broodPattern) : undefined,
openBroodPresent,
cappedBroodPresent,
queenSeen,
eggsPresent,
queenCellsPresent,
swarmMood: swarmMood ? Number(swarmMood) : undefined,
gentleness: gentleness ? Number(gentleness) : undefined,
combBehavior: combBehavior ? Number(combBehavior) : undefined,
feedStatus,
honeySuperStatus: honeySuperStatus || undefined,
healthIssues: healthIssues || undefined,
varroaIssues: varroaIssues || undefined,
measures: measures || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined // After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated. // show a stale list until each is invalidated.
for (const affectedColonyId of new Set([inspection.colonyId, colonyId])) { for (const affectedColonyId of new Set([inspection.colonyId, input.colonyId])) {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "inspections"], queryKey: ["colonies", affectedColonyId, "inspections"],
}); });
} }
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(
errorMessage(error, "Durchsicht konnte nicht gespeichert werden."),
),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
mutation.mutate(); setSubmitError(null);
if (!values.date) {
setDateError(DATE_REQUIRED_MESSAGE);
return;
} }
mutation.mutate(values);
}
const hasExtras = hasAnyValue(
values.weather,
values.temperature,
values.honeySuperStatus,
values.varroaIssues,
values.healthIssues,
values.measures,
values.notes,
);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Durchsicht bearbeiten</DialogTitle> <DialogTitle>Durchsicht bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection step={1} title="Welches Volk, wann?">
<ColonySelectField <ColonySelectField
id="inspection-edit-colony" id="inspection-edit-colony"
value={colonyId} value={values.colonyId}
onChange={setColonyId} onChange={(colonyId) => set("colonyId", colonyId)}
/> />
<div className="flex flex-col gap-2"> <Field label="Datum" required error={dateError}>
<Label htmlFor="inspection-edit-date">Datum</Label> {({ id, describedBy, invalid }) => (
<Input <Input
id="inspection-edit-date" id={id}
type="date" type="date"
value={date} required
onChange={(event) => setDate(event.target.value)} 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="Königin & Brut" description="Antippen, was du gesehen hast.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ToggleChip
label="Königin gesehen"
checked={values.queenSeen}
onChange={(checked) => set("queenSeen", checked)}
/>
<ToggleChip
label="Stifte vorhanden"
checked={values.eggsPresent}
onChange={(checked) => set("eggsPresent", checked)}
/>
<ToggleChip
label="Offene Brut"
checked={values.openBroodPresent}
onChange={(checked) => set("openBroodPresent", checked)}
/>
<ToggleChip
label="Verdeckelte Brut"
checked={values.cappedBroodPresent}
onChange={(checked) => set("cappedBroodPresent", checked)}
/>
<ToggleChip
label="Weiselzellen"
checked={values.queenCellsPresent}
onChange={(checked) => set("queenCellsPresent", checked)}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <Field label="Brutbild">
<Label htmlFor="inspection-edit-weather">Wetter</Label> {({ id, labelId, describedBy }) => (
<Input <RatingScale
id="inspection-edit-weather" id={id}
value={weather} labelledBy={labelId}
onChange={(event) => setWeather(event.target.value)} describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-edit-temperature">Temperatur (°C)</Label> <Field label="Brutmenge" hint="z. B. „4 Waben“">
{({ id, describedBy }) => (
<Input <Input
id="inspection-edit-temperature" id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.colonyStrength}
onChange={(event) => set("colonyStrength", event.target.value)}
/>
)}
</Field>
{RATINGS.map((rating) => (
<Field key={rating.key} label={rating.label}>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values[rating.key]}
onChange={(next) => set(rating.key, next)}
lowLabel={rating.low}
highLabel={rating.high}
/>
)}
</Field>
))}
</FormSection>
<FormSection step={4} title="Futter">
<div role="radiogroup" aria-label="Futterstatus" className="grid grid-cols-3 gap-2">
{FEED_STATUS_OPTIONS.map((option) => {
const selected = values.feedStatus === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("feedStatus", option.value)}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
selected
? "border-primary bg-primary font-medium text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
</FormSection>
<FormSection
step={5}
title="Weitere Angaben"
collapsible
defaultOpen={hasExtras}
fieldCount={7}
>
<Field label="Wetter">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.weather}
onChange={(event) => set("weather", event.target.value)}
/>
)}
</Field>
<Field label="Temperatur (°C)">
{({ id }) => (
<Input
id={id}
type="number" type="number"
inputMode="decimal" inputMode="decimal"
value={temperature} className="h-11"
onChange={(event) => setTemperature(event.target.value)} value={values.temperature}
onChange={(event) => set("temperature", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label> <Field label="Honigraum">
{({ id }) => (
<Input <Input
id="inspection-edit-strength" id={id}
type="number" className="h-11"
value={colonyStrength} value={values.honeySuperStatus}
onChange={(event) => setColonyStrength(event.target.value)} onChange={(event) => set("honeySuperStatus", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-edit-brood-amount">Brutmenge</Label> <Field label="Varroa-Befund">
{({ id }) => (
<Input <Input
id="inspection-edit-brood-amount" id={id}
value={broodAmount} className="h-11"
onChange={(event) => setBroodAmount(event.target.value)} value={values.varroaIssues}
onChange={(event) => set("varroaIssues", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-edit-brood-pattern">Brutbild (1–5)</Label> <Field label="Gesundheitsauffälligkeiten">
{({ id }) => (
<Input <Input
id="inspection-edit-brood-pattern" id={id}
type="number" className="h-11"
min={1} value={values.healthIssues}
max={5} onChange={(event) => set("healthIssues", event.target.value)}
value={broodPattern}
onChange={(event) => setBroodPattern(event.target.value)}
/> />
</div> )}
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-open-brood"> </Field>
<input <Field label="Maßnahmen">
id="inspection-edit-open-brood" {({ id }) => (
type="checkbox"
className="size-5"
checked={openBroodPresent}
onChange={(event) => setOpenBroodPresent(event.target.checked)}
/>
Offene Brut vorhanden
</label>
<label
className="flex items-center gap-3 text-sm"
htmlFor="inspection-edit-capped-brood"
>
<input
id="inspection-edit-capped-brood"
type="checkbox"
className="size-5"
checked={cappedBroodPresent}
onChange={(event) => setCappedBroodPresent(event.target.checked)}
/>
Verdeckelte Brut vorhanden
</label>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-queen">
<input
id="inspection-edit-queen"
type="checkbox"
className="size-5"
checked={queenSeen}
onChange={(event) => setQueenSeen(event.target.checked)}
/>
Königin gesehen
</label>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-eggs">
<input
id="inspection-edit-eggs"
type="checkbox"
className="size-5"
checked={eggsPresent}
onChange={(event) => setEggsPresent(event.target.checked)}
/>
Stifte vorhanden
</label>
<label
className="flex items-center gap-3 text-sm"
htmlFor="inspection-edit-queen-cells"
>
<input
id="inspection-edit-queen-cells"
type="checkbox"
className="size-5"
checked={queenCellsPresent}
onChange={(event) => setQueenCellsPresent(event.target.checked)}
/>
Weiselzellen vorhanden
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-swarm-mood">Schwarmstimmung (1–5)</Label>
<Input
id="inspection-edit-swarm-mood"
type="number"
min={1}
max={5}
value={swarmMood}
onChange={(event) => setSwarmMood(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-gentleness">Sanftmut (1–5)</Label>
<Input
id="inspection-edit-gentleness"
type="number"
min={1}
max={5}
value={gentleness}
onChange={(event) => setGentleness(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-comb-behavior">Wabensitz (1–5)</Label>
<Input
id="inspection-edit-comb-behavior"
type="number"
min={1}
max={5}
value={combBehavior}
onChange={(event) => setCombBehavior(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-feed">Futterstatus</Label>
<Select
value={feedStatus}
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
>
<SelectTrigger id="inspection-edit-feed" className="w-full">
<SelectValue>{FEED_STATUS_LABELS[feedStatus] ?? feedStatus}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ausreichend">Ausreichend</SelectItem>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="kritisch">Kritisch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-honey-super">Honigraumstatus</Label>
<Input
id="inspection-edit-honey-super"
value={honeySuperStatus}
onChange={(event) => setHoneySuperStatus(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-varroa">Varroa-Befund</Label>
<Input
id="inspection-edit-varroa"
value={varroaIssues}
onChange={(event) => setVarroaIssues(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-health">Gesundheitsauffälligkeiten</Label>
<Input
id="inspection-edit-health"
value={healthIssues}
onChange={(event) => setHealthIssues(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-measures">Maßnahmen</Label>
<Textarea <Textarea
id="inspection-edit-measures" id={id}
value={measures} value={values.measures}
onChange={(event) => setMeasures(event.target.value)} onChange={(event) => set("measures", event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="inspection-edit-notes">Notizen</Label> <Field label="Notizen">
{({ id }) => (
<Textarea <Textarea
id="inspection-edit-notes" id={id}
value={notes} value={values.notes}
onChange={(event) => setNotes(event.target.value)} onChange={(event) => set("notes", event.target.value)}
/> />
</div> )}
<Button type="submit" disabled={mutation.isPending}> </Field>
{mutation.isPending ? "Speichert…" : "Speichern"} </FormSection>
</Button>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,15 +1,12 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateSwarmControlCheck } from "@/lib/swarm-control/api"; import { updateSwarmControlCheck } 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, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types"; import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types";
import { Button } from "@/components/ui/button"; import { errorMessage } from "@/lib/forms/error-message";
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,
@@ -19,6 +16,9 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,6 +26,12 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) { export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -38,6 +44,8 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
const [actionTaken, setActionTaken] = useState<SwarmControlAction>(check.actionTaken); const [actionTaken, setActionTaken] = useState<SwarmControlAction>(check.actionTaken);
const [nextCheckDate, setNextCheckDate] = useState(check.nextCheckDate?.slice(0, 10) ?? ""); const [nextCheckDate, setNextCheckDate] = useState(check.nextCheckDate?.slice(0, 10) ?? "");
const [notes, setNotes] = useState(check.notes ?? ""); const [notes, setNotes] = useState(check.notes ?? "");
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -61,73 +69,80 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
} }
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Schwarmkontrolle konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
if (!date) {
setDateError(DATE_REQUIRED_MESSAGE);
return;
}
mutation.mutate(); mutation.mutate();
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle> <DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection step={1} title="Welches Volk, wann?">
<ColonySelectField <ColonySelectField
id="swarm-edit-colony" id="swarm-edit-colony"
value={colonyId} value={colonyId}
onChange={setColonyId} onChange={setColonyId}
/> />
<div className="flex flex-col gap-2"> <Field label="Datum" required error={dateError}>
<Label htmlFor="swarm-edit-date">Datum</Label> {({ id, describedBy, invalid }) => (
<Input <Input
id="swarm-edit-date" id={id}
type="date" type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date} value={date}
onChange={(event) => setDate(event.target.value)} onChange={(event) => {
setDate(event.target.value);
setDateError(null);
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="swarm-edit-cells-found">Weiselzellen gefunden</Label> </FormSection>
<FormSection step={2} title="Befund">
<Field label="Weiselzellen gefunden" hint="Anzahl">
{({ id, describedBy }) => (
<Input <Input
id="swarm-edit-cells-found" id={id}
type="number" type="number"
inputMode="numeric"
min={0} min={0}
className="h-11"
aria-describedby={describedBy}
value={queenCellsFound} value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)} onChange={(event) => setQueenCellsFound(event.target.value)}
/> />
</div> )}
<label </Field>
className="flex h-9 items-center gap-3 text-sm" <ToggleChip
htmlFor="swarm-edit-cells-capped" label="Weiselzellen bereits verdeckelt (akut)"
>
<input
id="swarm-edit-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped} checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)} onChange={setQueenCellsCapped}
/> />
Weiselzellen bereits verdeckelt (akut) <Field label="Schwarmstimmung" required>
</label> {({ id }) => (
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-mood">Schwarmstimmung</Label>
<Select <Select
value={swarmMood} value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)} onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
> >
<SelectTrigger id="swarm-edit-mood" className="w-full"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue> <SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -138,16 +153,20 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="swarm-edit-action">Maßnahme</Label> </FormSection>
<FormSection step={3} title="Maßnahme">
<Field label="Maßnahme" required>
{({ id }) => (
<Select <Select
value={actionTaken} value={actionTaken}
onValueChange={(value) => onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction) setActionTaken((value ?? actionTaken) as SwarmControlAction)
} }
> >
<SelectTrigger id="swarm-edit-action" className="w-full"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue> <SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -158,27 +177,36 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="swarm-edit-next-check">Nächste Kontrolle</Label> <Field label="Nächste Kontrolle">
{({ id }) => (
<Input <Input
id="swarm-edit-next-check" id={id}
type="date" type="date"
className="h-11"
value={nextCheckDate} value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)} onChange={(event) => setNextCheckDate(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="swarm-edit-notes">Notizen</Label> </FormSection>
<Textarea
id="swarm-edit-notes" <FormSection
value={notes} step={4}
onChange={(event) => setNotes(event.target.value)} title="Notizen"
/> collapsible
</div> defaultOpen={hasAnyValue(notes)}
<Button type="submit" disabled={mutation.isPending}> fieldCount={1}
{mutation.isPending ? "Speichert…" : "Speichern"} >
</Button> <Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,16 +1,15 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateTreatment } from "@/lib/treatments/api"; import { updateTreatment } from "@/lib/treatments/api";
import { Treatment } from "@/lib/treatments/types"; import { Treatment } from "@/lib/treatments/types";
import { Button } from "@/components/ui/button"; import { errorMessage } from "@/lib/forms/error-message";
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 { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -18,6 +17,17 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
interface FieldErrors {
date?: string;
product?: string;
}
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -39,6 +49,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState( const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
treatment.withdrawalPeriodDays?.toString() ?? "", treatment.withdrawalPeriodDays?.toString() ?? "",
); );
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -69,168 +81,221 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
} }
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Behandlung konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
const errors: FieldErrors = {
date: date ? undefined : DATE_REQUIRED_MESSAGE,
product: product.trim() ? undefined : "Bitte das verwendete Mittel angeben.",
};
setFieldErrors(errors);
if (errors.date || errors.product) {
return;
}
mutation.mutate(); mutation.mutate();
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Behandlung bearbeiten</DialogTitle> <DialogTitle>Behandlung bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection step={1} title="Welches Volk, wann?">
<ColonySelectField <ColonySelectField
id="treatment-edit-colony" id="treatment-edit-colony"
value={colonyId} value={colonyId}
onChange={setColonyId} onChange={setColonyId}
/> />
<div className="flex flex-col gap-2"> <Field label="Datum" required error={fieldErrors.date}>
<Label htmlFor="treatment-edit-date">Datum</Label> {({ id, describedBy, invalid }) => (
<Input <Input
id="treatment-edit-date" id={id}
type="date" type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-start-date">Beginn</Label>
<Input
id="treatment-edit-start-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-end-date">Ende</Label>
<Input
id="treatment-edit-end-date"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-product">Mittel</Label>
<Input
id="treatment-edit-product"
required required
value={product} className="h-11"
onChange={(event) => setProduct(event.target.value)} aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => {
setDate(event.target.value);
setFieldErrors((current) => ({ ...current, date: undefined }));
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-active-ingredient">Wirkstoff</Label> </FormSection>
<FormSection step={2} title="Mittel">
<Field label="Mittel" required error={fieldErrors.product}>
{({ id, describedBy, invalid }) => (
<Input <Input
id="treatment-edit-active-ingredient" id={id}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={product}
onChange={(event) => {
setProduct(event.target.value);
setFieldErrors((current) => ({ ...current, product: undefined }));
}}
/>
)}
</Field>
<Field label="Wirkstoff">
{({ id }) => (
<Input
id={id}
className="h-11"
value={activeIngredient} value={activeIngredient}
onChange={(event) => setActiveIngredient(event.target.value)} onChange={(event) => setActiveIngredient(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-amount">Menge</Label> <Field label="Menge">
{({ id }) => (
<Input <Input
id="treatment-edit-amount" id={id}
className="h-11"
value={amount} value={amount}
onChange={(event) => setAmount(event.target.value)} onChange={(event) => setAmount(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-method">Methode</Label> <Field label="Methode">
{({ id }) => (
<Input <Input
id="treatment-edit-method" id={id}
className="h-11"
value={method} value={method}
onChange={(event) => setMethod(event.target.value)} onChange={(event) => setMethod(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-reason">Grund</Label> <Field label="Grund">
{({ id }) => (
<Input <Input
id="treatment-edit-reason" id={id}
className="h-11"
value={reason} value={reason}
onChange={(event) => setReason(event.target.value)} onChange={(event) => setReason(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-batch">Chargennummer</Label> </FormSection>
<FormSection step={3} title="Zeitraum & Wartezeit">
<div className="grid grid-cols-2 gap-3">
<Field label="Beginn">
{({ id }) => (
<Input <Input
id="treatment-edit-batch" id={id}
value={batchNumber} type="date"
onChange={(event) => setBatchNumber(event.target.value)} className="h-11"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label> <Field label="Ende">
{({ id }) => (
<Input <Input
id="treatment-edit-withdrawal" id={id}
type="date"
className="h-11"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
)}
</Field>
</div>
<Field label="Wartezeit (Tage)">
{({ id }) => (
<Input
id={id}
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
className="h-11"
value={withdrawalPeriodDays} value={withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)} onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/> />
</div> )}
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3"> </Field>
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend> </FormSection>
<p className="text-xs text-muted-foreground">
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 <FormSection
VO (EU) 2019/6). Belege 5 Jahre aufbewahren. step={4}
</p> title="Für das Bestandsbuch"
<div className="flex flex-col gap-2"> description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
<Label htmlFor="treatment-edit-supplier">Lieferant (Name, Anschrift)</Label> >
<Field label="Chargennummer">
{({ id }) => (
<Input <Input
id="treatment-edit-supplier" id={id}
className="h-11"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
)}
</Field>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11" className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster" placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier} value={supplier}
onChange={(event) => setSupplier(event.target.value)} onChange={(event) => setSupplier(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-proof">Beleg für den Erwerb</Label> <Field label="Beleg für den Erwerb">
{({ id }) => (
<Input <Input
id="treatment-edit-proof" id={id}
className="h-11" className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026" placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof} value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)} onChange={(event) => setPurchaseProof(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="treatment-edit-vet">Verschreibender Tierarzt (falls zutreffend)</Label> <Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend.">
{({ id, describedBy }) => (
<Input <Input
id="treatment-edit-vet" id={id}
className="h-11" className="h-11"
aria-describedby={describedBy}
value={prescribingVet} value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)} onChange={(event) => setPrescribingVet(event.target.value)}
/> />
</div> )}
</fieldset> </Field>
<div className="flex flex-col gap-2"> </FormSection>
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea <FormSection
id="treatment-edit-notes" step={5}
value={notes} title="Notizen"
onChange={(event) => setNotes(event.target.value)} collapsible
/> defaultOpen={hasAnyValue(notes)}
</div> fieldCount={1}
<Button type="submit" disabled={mutation.isPending}> >
{mutation.isPending ? "Speichert…" : "Speichern"} <Field label="Notizen">
</Button> {({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,15 +1,12 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateVarroaMeasurement } from "@/lib/varroa/api"; import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types"; import { VarroaMeasurement, 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 { Button } from "@/components/ui/button"; import { errorMessage } from "@/lib/forms/error-message";
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,
@@ -19,6 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,6 +25,12 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) { export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -42,6 +47,8 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
); );
const [assessment, setAssessment] = useState(measurement.assessment ?? ""); const [assessment, setAssessment] = useState(measurement.assessment ?? "");
const [notes, setNotes] = useState(measurement.notes ?? ""); const [notes, setNotes] = useState(measurement.notes ?? "");
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -65,50 +72,61 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
} }
setOpen(false); setOpen(false);
}, },
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"), onError: (error) =>
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
if (!date) {
setDateError(DATE_REQUIRED_MESSAGE);
return;
}
mutation.mutate(); mutation.mutate();
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger render={<EditTriggerButton />} />
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Varroamessung bearbeiten</DialogTitle> <DialogTitle>Varroamessung bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection step={1} title="Welches Volk, wann?">
<ColonySelectField <ColonySelectField
id="varroa-edit-colony" id="varroa-edit-colony"
value={colonyId} value={colonyId}
onChange={setColonyId} onChange={setColonyId}
/> />
<div className="flex flex-col gap-2"> <Field label="Datum" required error={dateError}>
<Label htmlFor="varroa-edit-date">Datum</Label> {({ id, describedBy, invalid }) => (
<Input <Input
id="varroa-edit-date" id={id}
type="date" type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date} value={date}
onChange={(event) => setDate(event.target.value)} onChange={(event) => {
setDate(event.target.value);
setDateError(null);
}}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="varroa-edit-method">Methode</Label> </FormSection>
<FormSection step={2} title="Messung">
<Field label="Methode" required>
{({ id }) => (
<Select <Select
value={method} value={method}
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)} onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
> >
<SelectTrigger id="varroa-edit-method" className="w-full"> <SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue> <SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -119,57 +137,77 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="varroa-edit-count">Milbenzahl</Label> <div className="grid grid-cols-2 gap-3">
<Field label="Milbenzahl">
{({ id }) => (
<Input <Input
id="varroa-edit-count" id={id}
type="number" type="number"
inputMode="numeric"
min={0} min={0}
className="h-11"
value={miteCount} value={miteCount}
onChange={(event) => setMiteCount(event.target.value)} onChange={(event) => setMiteCount(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="varroa-edit-duration">Zeitraum (Minuten)</Label> <Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input <Input
id="varroa-edit-duration" id={id}
type="number" type="number"
inputMode="numeric"
min={0} min={0}
className="h-11"
value={durationMinutes} value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)} onChange={(event) => setDurationMinutes(event.target.value)}
/> />
)}
</Field>
</div> </div>
<div className="flex flex-col gap-2"> <Field label="Milben pro Tag">
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label> {({ id }) => (
<Input <Input
id="varroa-edit-calculated" id={id}
type="number" type="number"
inputMode="decimal"
step="0.1" step="0.1"
min={0} min={0}
className="h-11"
value={calculatedValue} value={calculatedValue}
onChange={(event) => setCalculatedValue(event.target.value)} onChange={(event) => setCalculatedValue(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="varroa-edit-assessment">Einschätzung</Label> </FormSection>
<FormSection
step={3}
title="Einschätzung & Notizen"
collapsible
defaultOpen={hasAnyValue(assessment, notes)}
fieldCount={2}
>
<Field label="Einschätzung">
{({ id }) => (
<Input <Input
id="varroa-edit-assessment" id={id}
className="h-11"
value={assessment} value={assessment}
onChange={(event) => setAssessment(event.target.value)} onChange={(event) => setAssessment(event.target.value)}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="varroa-edit-notes">Notizen</Label> <Field label="Notizen">
<Textarea {({ id }) => (
id="varroa-edit-notes" <Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
value={notes} )}
onChange={(event) => setNotes(event.target.value)} </Field>
/> </FormSection>
</div>
<Button type="submit" disabled={mutation.isPending}> <DialogSubmitBar isPending={mutation.isPending} error={submitError} />
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>