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
+1395 -852

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,46 +185,51 @@ 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(); <Input
mutation.mutate(); id={id}
}} required
> className="h-11"
<div className="flex flex-col gap-2"> aria-describedby={describedBy}
<Label htmlFor="split-code">Code des Ablegers</Label> aria-invalid={invalid}
<Input value={code}
id="split-code" onChange={(event) => {
required setCode(event.target.value);
value={code} setCodeError(null);
onChange={(event) => setCode(event.target.value)} }}
/> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="split-type">Volkstyp</Label> <Field label="Volkstyp" required>
<Select {({ id }) => (
value={colonyType} <Select
onValueChange={(value) => setColonyType(value as ColonyType)} value={colonyType}
> onValueChange={(value) => setColonyType(value as ColonyType)}
<SelectTrigger id="split-type"> >
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue> <SelectTrigger id={id} className="h-11 w-full">
</SelectTrigger> <SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
<SelectContent> </SelectTrigger>
{COLONY_TYPES.map((type) => ( <SelectContent>
<SelectItem key={type} value={type}> {COLONY_TYPES.map((type) => (
{COLONY_TYPE_LABELS[type]} <SelectItem key={type} value={type}>
</SelectItem> {COLONY_TYPE_LABELS[type]}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</div> </Select>
<DialogFooter> )}
<Button type="submit" disabled={mutation.isPending}> </Field>
Ableger anlegen <ActionFooter error={error}>
<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,52 +272,66 @@ 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>
{(queens ?? []) {(queens ?? [])
.filter((queen) => queen.status === "ACTIVE") .filter((queen) => queen.status === "ACTIVE")
.map((queen) => ( .map((queen) => (
<SelectItem key={queen.id} value={queen.id}> <SelectItem key={queen.id} value={queen.id}>
{queen.code} {queen.code}
</SelectItem> </SelectItem>
))} ))}
</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(); <Select
mutation.mutate(); value={mergedColonyId}
}} onValueChange={(value) => {
> setMergedColonyId(value ?? "");
<div className="flex flex-col gap-2"> setError(null);
<Label htmlFor="merge-colony">Zu vereinigendes Volk</Label> }}
<Select >
value={mergedColonyId} <SelectTrigger id={id} className="h-11 w-full">
onValueChange={(value) => setMergedColonyId(value ?? "")} <SelectValue placeholder="Volk wählen" />
> </SelectTrigger>
<SelectTrigger id="merge-colony"> <SelectContent>
<SelectValue placeholder="Volk wählen" /> {candidates.map((candidate) => (
</SelectTrigger> <SelectItem key={candidate.id} value={candidate.id}>
<SelectContent> {candidate.code}
{(colonies ?? [])
.filter((c) => c.id !== colony.id && c.status === "ACTIVE")
.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.code}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> )}
<DialogFooter> </Field>
<Button type="submit" disabled={!mergedColonyId || mutation.isPending}> <ActionFooter error={error}>
Vereinigen <ConfirmDialog
</Button> triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"}
</DialogFooter> triggerVariant="default"
</form> 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>
</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,108 +74,150 @@ 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>
<ColonySelectField <FormSection step={1} title="Welches Volk, wann?">
id="feeding-edit-colony" <ColonySelectField
value={colonyId} id="feeding-edit-colony"
onChange={setColonyId} value={colonyId}
/> onChange={setColonyId}
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-date">Datum</Label>
<Input
id="feeding-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/> />
</div> <Field label="Datum" required error={fieldErrors.date}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label htmlFor="feeding-edit-type">Futterart</Label> <Input
<Select id={id}
value={feedType} type="date"
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)} required
> className="h-11"
<SelectTrigger id="feeding-edit-type" className="w-full"> aria-describedby={describedBy}
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue> aria-invalid={invalid}
</SelectTrigger> value={date}
<SelectContent> onChange={(event) => {
{FEED_TYPES.map((option) => ( setDate(event.target.value);
<SelectItem key={option} value={option}> setFieldErrors((current) => ({ ...current, date: undefined }));
{FEED_TYPE_LABELS[option]} }}
</SelectItem> />
))} )}
</SelectContent> </Field>
</Select> </FormSection>
</div>
<div className="flex gap-3"> <FormSection step={2} title="Was und wie viel?">
<div className="flex flex-1 flex-col gap-2"> <Field label="Futterart" required>
<Label htmlFor="feeding-edit-amount">Menge</Label> {({ id }) => (
<Input <Select
id="feeding-edit-amount" value={feedType}
type="number" onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
min={0} >
value={amount} <SelectTrigger id={id} className="h-11 w-full">
onChange={(event) => setAmount(event.target.value)} <SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
/> </SelectTrigger>
<SelectContent>
{FEED_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{FEED_TYPE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Menge" required error={fieldErrors.amount}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="number"
inputMode="decimal"
min={0}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={amount}
onChange={(event) => {
setAmount(event.target.value);
setFieldErrors((current) => ({ ...current, amount: undefined }));
}}
/>
)}
</Field>
<Field label="Einheit" required error={fieldErrors.unit}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={unit}
onChange={(event) => {
setUnit(event.target.value);
setFieldErrors((current) => ({ ...current, unit: undefined }));
}}
/>
)}
</Field>
</div> </div>
<div className="flex flex-1 flex-col gap-2"> <Field label="Produkt">
<Label htmlFor="feeding-edit-unit">Einheit</Label> {({ id }) => (
<Input <Input
id="feeding-edit-unit" id={id}
value={unit} className="h-11"
onChange={(event) => setUnit(event.target.value)} value={product}
/> onChange={(event) => setProduct(event.target.value)}
</div> />
</div> )}
<div className="flex flex-col gap-2"> </Field>
<Label htmlFor="feeding-edit-product">Produkt</Label> </FormSection>
<Input
id="feeding-edit-product" <FormSection
value={product} step={3}
onChange={(event) => setProduct(event.target.value)} title="Weitere Angaben"
/> collapsible
</div> defaultOpen={hasAnyValue(batchNumber, notes)}
<div className="flex flex-col gap-2"> fieldCount={2}
<Label htmlFor="feeding-edit-batch">Chargennummer</Label> >
<Input <Field label="Chargennummer">
id="feeding-edit-batch" {({ id }) => (
value={batchNumber} <Input
onChange={(event) => setBatchNumber(event.target.value)} id={id}
/> className="h-11"
</div> value={batchNumber}
<div className="flex flex-col gap-2"> onChange={(event) => setBatchNumber(event.target.value)}
<Label htmlFor="feeding-edit-notes">Notizen</Label> />
<Textarea )}
id="feeding-edit-notes" </Field>
value={notes} <Field label="Notizen">
onChange={(event) => setNotes(event.target.value)} {({ id }) => (
/> <Textarea id={id} value={notes} onChange={(event) => setNotes(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>
@@ -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>
<ColonySelectField <FormSection step={1} title="Welches Volk, wann?">
id="inspection-edit-colony" <ColonySelectField
value={colonyId} id="inspection-edit-colony"
onChange={setColonyId} value={values.colonyId}
/> onChange={(colonyId) => set("colonyId", colonyId)}
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-date">Datum</Label>
<Input
id="inspection-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/> />
</div> <Field label="Datum" required error={dateError}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label htmlFor="inspection-edit-weather">Wetter</Label> <Input
<Input id={id}
id="inspection-edit-weather" type="date"
value={weather} required
onChange={(event) => setWeather(event.target.value)} className="h-11"
/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
<div className="flex flex-col gap-2"> value={values.date}
<Label htmlFor="inspection-edit-temperature">Temperatur (°C)</Label> onChange={(event) => set("date", event.target.value)}
<Input />
id="inspection-edit-temperature" )}
type="number" </Field>
inputMode="decimal" </FormSection>
value={temperature}
onChange={(event) => setTemperature(event.target.value)} <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">
</div> <ToggleChip
<div className="flex flex-col gap-2"> label="Königin gesehen"
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label> checked={values.queenSeen}
<Input onChange={(checked) => set("queenSeen", checked)}
id="inspection-edit-strength" />
type="number" <ToggleChip
value={colonyStrength} label="Stifte vorhanden"
onChange={(event) => setColonyStrength(event.target.value)} checked={values.eggsPresent}
/> onChange={(checked) => set("eggsPresent", checked)}
</div> />
<div className="flex flex-col gap-2"> <ToggleChip
<Label htmlFor="inspection-edit-brood-amount">Brutmenge</Label> label="Offene Brut"
<Input checked={values.openBroodPresent}
id="inspection-edit-brood-amount" onChange={(checked) => set("openBroodPresent", checked)}
value={broodAmount} />
onChange={(event) => setBroodAmount(event.target.value)} <ToggleChip
/> label="Verdeckelte Brut"
</div> checked={values.cappedBroodPresent}
<div className="flex flex-col gap-2"> onChange={(checked) => set("cappedBroodPresent", checked)}
<Label htmlFor="inspection-edit-brood-pattern">Brutbild (1–5)</Label> />
<Input <ToggleChip
id="inspection-edit-brood-pattern" label="Weiselzellen"
type="number" checked={values.queenCellsPresent}
min={1} onChange={(checked) => set("queenCellsPresent", checked)}
max={5} />
value={broodPattern} </div>
onChange={(event) => setBroodPattern(event.target.value)} <Field label="Brutbild">
/> {({ id, labelId, describedBy }) => (
</div> <RatingScale
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-open-brood"> id={id}
<input labelledBy={labelId}
id="inspection-edit-open-brood" describedBy={describedBy}
type="checkbox" value={values.broodPattern}
className="size-5" onChange={(rating) => set("broodPattern", rating)}
checked={openBroodPresent} lowLabel="lückenhaft"
onChange={(event) => setOpenBroodPresent(event.target.checked)} highLabel="geschlossen"
/> />
Offene Brut vorhanden )}
</label> </Field>
<label <Field label="Brutmenge" hint="z. B. „4 Waben“">
className="flex items-center gap-3 text-sm" {({ id, describedBy }) => (
htmlFor="inspection-edit-capped-brood" <Input
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}
> >
<input <Field label="Wetter">
id="inspection-edit-capped-brood" {({ id }) => (
type="checkbox" <Input
className="size-5" id={id}
checked={cappedBroodPresent} className="h-11"
onChange={(event) => setCappedBroodPresent(event.target.checked)} value={values.weather}
/> onChange={(event) => set("weather", event.target.value)}
Verdeckelte Brut vorhanden />
</label> )}
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-queen"> </Field>
<input <Field label="Temperatur (°C)">
id="inspection-edit-queen" {({ id }) => (
type="checkbox" <Input
className="size-5" id={id}
checked={queenSeen} type="number"
onChange={(event) => setQueenSeen(event.target.checked)} inputMode="decimal"
/> className="h-11"
Königin gesehen value={values.temperature}
</label> onChange={(event) => set("temperature", event.target.value)}
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-eggs"> />
<input )}
id="inspection-edit-eggs" </Field>
type="checkbox" <Field label="Honigraum">
className="size-5" {({ id }) => (
checked={eggsPresent} <Input
onChange={(event) => setEggsPresent(event.target.checked)} id={id}
/> className="h-11"
Stifte vorhanden value={values.honeySuperStatus}
</label> onChange={(event) => set("honeySuperStatus", event.target.value)}
<label />
className="flex items-center gap-3 text-sm" )}
htmlFor="inspection-edit-queen-cells" </Field>
> <Field label="Varroa-Befund">
<input {({ id }) => (
id="inspection-edit-queen-cells" <Input
type="checkbox" id={id}
className="size-5" className="h-11"
checked={queenCellsPresent} value={values.varroaIssues}
onChange={(event) => setQueenCellsPresent(event.target.checked)} onChange={(event) => set("varroaIssues", event.target.value)}
/> />
Weiselzellen vorhanden )}
</label> </Field>
<div className="flex flex-col gap-2"> <Field label="Gesundheitsauffälligkeiten">
<Label htmlFor="inspection-edit-swarm-mood">Schwarmstimmung (1–5)</Label> {({ id }) => (
<Input <Input
id="inspection-edit-swarm-mood" id={id}
type="number" className="h-11"
min={1} value={values.healthIssues}
max={5} onChange={(event) => set("healthIssues", event.target.value)}
value={swarmMood} />
onChange={(event) => setSwarmMood(event.target.value)} )}
/> </Field>
</div> <Field label="Maßnahmen">
<div className="flex flex-col gap-2"> {({ id }) => (
<Label htmlFor="inspection-edit-gentleness">Sanftmut (1–5)</Label> <Textarea
<Input id={id}
id="inspection-edit-gentleness" value={values.measures}
type="number" onChange={(event) => set("measures", event.target.value)}
min={1} />
max={5} )}
value={gentleness} </Field>
onChange={(event) => setGentleness(event.target.value)} <Field label="Notizen">
/> {({ id }) => (
</div> <Textarea
<div className="flex flex-col gap-2"> id={id}
<Label htmlFor="inspection-edit-comb-behavior">Wabensitz (1–5)</Label> value={values.notes}
<Input onChange={(event) => set("notes", event.target.value)}
id="inspection-edit-comb-behavior" />
type="number" )}
min={1} </Field>
max={5} </FormSection>
value={combBehavior}
onChange={(event) => setCombBehavior(event.target.value)} <DialogSubmitBar isPending={mutation.isPending} error={submitError} />
/>
</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
id="inspection-edit-measures"
value={measures}
onChange={(event) => setMeasures(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-notes">Notizen</Label>
<Textarea
id="inspection-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</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,124 +69,144 @@ 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>
<ColonySelectField <FormSection step={1} title="Welches Volk, wann?">
id="swarm-edit-colony" <ColonySelectField
value={colonyId} id="swarm-edit-colony"
onChange={setColonyId} value={colonyId}
/> onChange={setColonyId}
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-date">Datum</Label>
<Input
id="swarm-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/> />
</div> <Field label="Datum" required error={dateError}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label htmlFor="swarm-edit-cells-found">Weiselzellen gefunden</Label> <Input
<Input id={id}
id="swarm-edit-cells-found" type="date"
type="number" required
min={0} className="h-11"
value={queenCellsFound} aria-describedby={describedBy}
onChange={(event) => setQueenCellsFound(event.target.value)} aria-invalid={invalid}
/> value={date}
</div> onChange={(event) => {
<label setDate(event.target.value);
className="flex h-9 items-center gap-3 text-sm" setDateError(null);
htmlFor="swarm-edit-cells-capped" }}
> />
<input )}
id="swarm-edit-cells-capped" </Field>
type="checkbox" </FormSection>
className="size-4.5"
<FormSection step={2} title="Befund">
<Field label="Weiselzellen gefunden" hint="Anzahl">
{({ id, describedBy }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
aria-describedby={describedBy}
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
)}
</Field>
<ToggleChip
label="Weiselzellen bereits verdeckelt (akut)"
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"> <Select
<Label htmlFor="swarm-edit-mood">Schwarmstimmung</Label> value={swarmMood}
<Select onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
value={swarmMood} >
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)} <SelectTrigger id={id} className="h-11 w-full">
> <SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
<SelectTrigger id="swarm-edit-mood" className="w-full"> </SelectTrigger>
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue> <SelectContent>
</SelectTrigger> {(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectContent> <SelectItem key={option} value={option}>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => ( {SWARM_MOOD_LABELS[option]}
<SelectItem key={option} value={option}> </SelectItem>
{SWARM_MOOD_LABELS[option]} ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> )}
</Select> </Field>
</div> </FormSection>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-action">Maßnahme</Label> <FormSection step={3} title="Maßnahme">
<Select <Field label="Maßnahme" required>
value={actionTaken} {({ id }) => (
onValueChange={(value) => <Select
setActionTaken((value ?? actionTaken) as SwarmControlAction) value={actionTaken}
} onValueChange={(value) =>
> setActionTaken((value ?? actionTaken) as SwarmControlAction)
<SelectTrigger id="swarm-edit-action" className="w-full"> }
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue> >
</SelectTrigger> <SelectTrigger id={id} className="h-11 w-full">
<SelectContent> <SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => ( </SelectTrigger>
<SelectItem key={option} value={option}> <SelectContent>
{SWARM_ACTION_LABELS[option]} {(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
</SelectItem> <SelectItem key={option} value={option}>
))} {SWARM_ACTION_LABELS[option]}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
<div className="flex flex-col gap-2"> </Select>
<Label htmlFor="swarm-edit-next-check">Nächste Kontrolle</Label> )}
<Input </Field>
id="swarm-edit-next-check" <Field label="Nächste Kontrolle">
type="date" {({ id }) => (
value={nextCheckDate} <Input
onChange={(event) => setNextCheckDate(event.target.value)} id={id}
/> type="date"
</div> className="h-11"
<div className="flex flex-col gap-2"> value={nextCheckDate}
<Label htmlFor="swarm-edit-notes">Notizen</Label> onChange={(event) => setNextCheckDate(event.target.value)}
<Textarea />
id="swarm-edit-notes" )}
value={notes} </Field>
onChange={(event) => setNotes(event.target.value)} </FormSection>
/>
</div> <FormSection
<Button type="submit" disabled={mutation.isPending}> step={4}
{mutation.isPending ? "Speichert…" : "Speichern"} title="Notizen"
</Button> collapsible
defaultOpen={hasAnyValue(notes)}
fieldCount={1}
>
<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>
<ColonySelectField <FormSection step={1} title="Welches Volk, wann?">
id="treatment-edit-colony" <ColonySelectField
value={colonyId} id="treatment-edit-colony"
onChange={setColonyId} value={colonyId}
/> onChange={setColonyId}
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-date">Datum</Label>
<Input
id="treatment-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/> />
</div> <Field label="Datum" required error={fieldErrors.date}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label htmlFor="treatment-edit-start-date">Beginn</Label> <Input
<Input id={id}
id="treatment-edit-start-date" type="date"
type="date" required
value={startDate} className="h-11"
onChange={(event) => setStartDate(event.target.value)} aria-describedby={describedBy}
/> aria-invalid={invalid}
</div> value={date}
<div className="flex flex-col gap-2"> onChange={(event) => {
<Label htmlFor="treatment-edit-end-date">Ende</Label> setDate(event.target.value);
<Input setFieldErrors((current) => ({ ...current, date: undefined }));
id="treatment-edit-end-date" }}
type="date" />
value={endDate} )}
onChange={(event) => setEndDate(event.target.value)} </Field>
/> </FormSection>
</div>
<div className="flex flex-col gap-2"> <FormSection step={2} title="Mittel">
<Label htmlFor="treatment-edit-product">Mittel</Label> <Field label="Mittel" required error={fieldErrors.product}>
<Input {({ id, describedBy, invalid }) => (
id="treatment-edit-product" <Input
required id={id}
value={product} required
onChange={(event) => setProduct(event.target.value)} className="h-11"
/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
<div className="flex flex-col gap-2"> value={product}
<Label htmlFor="treatment-edit-active-ingredient">Wirkstoff</Label> onChange={(event) => {
<Input setProduct(event.target.value);
id="treatment-edit-active-ingredient" setFieldErrors((current) => ({ ...current, product: undefined }));
value={activeIngredient} }}
onChange={(event) => setActiveIngredient(event.target.value)} />
/> )}
</div> </Field>
<div className="flex flex-col gap-2"> <Field label="Wirkstoff">
<Label htmlFor="treatment-edit-amount">Menge</Label> {({ id }) => (
<Input <Input
id="treatment-edit-amount" id={id}
value={amount} className="h-11"
onChange={(event) => setAmount(event.target.value)} value={activeIngredient}
/> onChange={(event) => setActiveIngredient(event.target.value)}
</div> />
<div className="flex flex-col gap-2"> )}
<Label htmlFor="treatment-edit-method">Methode</Label> </Field>
<Input <Field label="Menge">
id="treatment-edit-method" {({ id }) => (
value={method} <Input
onChange={(event) => setMethod(event.target.value)} id={id}
/> className="h-11"
</div> value={amount}
<div className="flex flex-col gap-2"> onChange={(event) => setAmount(event.target.value)}
<Label htmlFor="treatment-edit-reason">Grund</Label> />
<Input )}
id="treatment-edit-reason" </Field>
value={reason} <Field label="Methode">
onChange={(event) => setReason(event.target.value)} {({ id }) => (
/> <Input
</div> id={id}
<div className="flex flex-col gap-2"> className="h-11"
<Label htmlFor="treatment-edit-batch">Chargennummer</Label> value={method}
<Input onChange={(event) => setMethod(event.target.value)}
id="treatment-edit-batch" />
value={batchNumber} )}
onChange={(event) => setBatchNumber(event.target.value)} </Field>
/> <Field label="Grund">
</div> {({ id }) => (
<div className="flex flex-col gap-2"> <Input
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label> id={id}
<Input className="h-11"
id="treatment-edit-withdrawal" value={reason}
type="number" onChange={(event) => setReason(event.target.value)}
inputMode="numeric" />
min={0} )}
value={withdrawalPeriodDays} </Field>
onChange={(event) => setWithdrawalPeriodDays(event.target.value)} </FormSection>
/>
</div> <FormSection step={3} title="Zeitraum & Wartezeit">
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3"> <div className="grid grid-cols-2 gap-3">
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend> <Field label="Beginn">
<p className="text-xs text-muted-foreground"> {({ id }) => (
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 <Input
VO (EU) 2019/6). Belege 5 Jahre aufbewahren. id={id}
</p> type="date"
<div className="flex flex-col gap-2"> className="h-11"
<Label htmlFor="treatment-edit-supplier">Lieferant (Name, Anschrift)</Label> value={startDate}
<Input onChange={(event) => setStartDate(event.target.value)}
id="treatment-edit-supplier" />
className="h-11" )}
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster" </Field>
value={supplier} <Field label="Ende">
onChange={(event) => setSupplier(event.target.value)} {({ id }) => (
/> <Input
id={id}
type="date"
className="h-11"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
)}
</Field>
</div> </div>
<div className="flex flex-col gap-2"> <Field label="Wartezeit (Tage)">
<Label htmlFor="treatment-edit-proof">Beleg für den Erwerb</Label> {({ id }) => (
<Input <Input
id="treatment-edit-proof" id={id}
className="h-11" type="number"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026" inputMode="numeric"
value={purchaseProof} min={0}
onChange={(event) => setPurchaseProof(event.target.value)} className="h-11"
/> value={withdrawalPeriodDays}
</div> onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
<div className="flex flex-col gap-2"> />
<Label htmlFor="treatment-edit-vet">Verschreibender Tierarzt (falls zutreffend)</Label> )}
<Input </Field>
id="treatment-edit-vet" </FormSection>
className="h-11"
value={prescribingVet} <FormSection
onChange={(event) => setPrescribingVet(event.target.value)} step={4}
/> title="Für das Bestandsbuch"
</div> description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
</fieldset> >
<div className="flex flex-col gap-2"> <Field label="Chargennummer">
<Label htmlFor="treatment-edit-notes">Notizen</Label> {({ id }) => (
<Textarea <Input
id="treatment-edit-notes" id={id}
value={notes} className="h-11"
onChange={(event) => setNotes(event.target.value)} value={batchNumber}
/> onChange={(event) => setBatchNumber(event.target.value)}
</div> />
<Button type="submit" disabled={mutation.isPending}> )}
{mutation.isPending ? "Speichert…" : "Speichern"} </Field>
</Button> <Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
)}
</Field>
<Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend.">
{({ id, describedBy }) => (
<Input
id={id}
className="h-11"
aria-describedby={describedBy}
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={5}
title="Notizen"
collapsible
defaultOpen={hasAnyValue(notes)}
fieldCount={1}
>
<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,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,111 +72,142 @@ 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>
<ColonySelectField <FormSection step={1} title="Welches Volk, wann?">
id="varroa-edit-colony" <ColonySelectField
value={colonyId} id="varroa-edit-colony"
onChange={setColonyId} value={colonyId}
/> onChange={setColonyId}
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-date">Datum</Label>
<Input
id="varroa-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/> />
</div> <Field label="Datum" required error={dateError}>
<div className="flex flex-col gap-2"> {({ id, describedBy, invalid }) => (
<Label htmlFor="varroa-edit-method">Methode</Label> <Input
<Select id={id}
value={method} type="date"
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)} required
> className="h-11"
<SelectTrigger id="varroa-edit-method" className="w-full"> aria-describedby={describedBy}
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue> aria-invalid={invalid}
</SelectTrigger> value={date}
<SelectContent> onChange={(event) => {
{VARROA_METHODS.map((option) => ( setDate(event.target.value);
<SelectItem key={option} value={option}> setDateError(null);
{VARROA_METHOD_LABELS[option]} }}
</SelectItem> />
))} )}
</SelectContent> </Field>
</Select> </FormSection>
</div>
<div className="flex flex-col gap-2"> <FormSection step={2} title="Messung">
<Label htmlFor="varroa-edit-count">Milbenzahl</Label> <Field label="Methode" required>
<Input {({ id }) => (
id="varroa-edit-count" <Select
type="number" value={method}
min={0} onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
value={miteCount} >
onChange={(event) => setMiteCount(event.target.value)} <SelectTrigger id={id} className="h-11 w-full">
/> <SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</div> </SelectTrigger>
<div className="flex flex-col gap-2"> <SelectContent>
<Label htmlFor="varroa-edit-duration">Zeitraum (Minuten)</Label> {VARROA_METHODS.map((option) => (
<Input <SelectItem key={option} value={option}>
id="varroa-edit-duration" {VARROA_METHOD_LABELS[option]}
type="number" </SelectItem>
min={0} ))}
value={durationMinutes} </SelectContent>
onChange={(event) => setDurationMinutes(event.target.value)} </Select>
/> )}
</div> </Field>
<div className="flex flex-col gap-2"> <div className="grid grid-cols-2 gap-3">
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label> <Field label="Milbenzahl">
<Input {({ id }) => (
id="varroa-edit-calculated" <Input
type="number" id={id}
step="0.1" type="number"
min={0} inputMode="numeric"
value={calculatedValue} min={0}
onChange={(event) => setCalculatedValue(event.target.value)} className="h-11"
/> value={miteCount}
</div> onChange={(event) => setMiteCount(event.target.value)}
<div className="flex flex-col gap-2"> />
<Label htmlFor="varroa-edit-assessment">Einschätzung</Label> )}
<Input </Field>
id="varroa-edit-assessment" <Field label="Zeitraum (Minuten)">
value={assessment} {({ id }) => (
onChange={(event) => setAssessment(event.target.value)} <Input
/> id={id}
</div> type="number"
<div className="flex flex-col gap-2"> inputMode="numeric"
<Label htmlFor="varroa-edit-notes">Notizen</Label> min={0}
<Textarea className="h-11"
id="varroa-edit-notes" value={durationMinutes}
value={notes} onChange={(event) => setDurationMinutes(event.target.value)}
onChange={(event) => setNotes(event.target.value)} />
/> )}
</div> </Field>
<Button type="submit" disabled={mutation.isPending}> </div>
{mutation.isPending ? "Speichert…" : "Speichern"} <Field label="Milben pro Tag">
</Button> {({ id }) => (
<Input
id={id}
type="number"
inputMode="decimal"
step="0.1"
min={0}
className="h-11"
value={calculatedValue}
onChange={(event) => setCalculatedValue(event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Einschätzung & Notizen"
collapsible
defaultOpen={hasAnyValue(assessment, notes)}
fieldCount={2}
>
<Field label="Einschätzung">
{({ id }) => (
<Input
id={id}
className="h-11"
value={assessment}
onChange={(event) => setAssessment(event.target.value)}
/>
)}
</Field>
<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>