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:
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";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, ReactNode, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
dissolveColony,
|
||||
mergeColony,
|
||||
@@ -16,13 +15,15 @@ import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { assignQueenColony, fetchQueens } from "@/lib/queens/api";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Field } from "@/components/form/field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -36,10 +37,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
|
||||
function invalidateColony(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
colonyId: string,
|
||||
@@ -49,23 +46,49 @@ function invalidateColony(
|
||||
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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [apiaryId, setApiaryId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
||||
});
|
||||
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => moveColony(colony.id, { apiaryId }),
|
||||
onSuccess: () => {
|
||||
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);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Volk konnte nicht umgesetzt werden")),
|
||||
onError: (mutationError) =>
|
||||
setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -80,36 +103,40 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Volk umsetzen</DialogTitle>
|
||||
<DialogDescription>
|
||||
{colony.code} zieht an einen anderen Bienenstand. Der Wechsel wird in der Historie
|
||||
festgehalten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="move-apiary">Neuer Bienenstand</Label>
|
||||
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}>
|
||||
<SelectTrigger id="move-apiary">
|
||||
<SelectValue placeholder="Bienenstand wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(apiaries ?? [])
|
||||
.filter((apiary) => apiary.id !== colony.apiaryId)
|
||||
.map((apiary) => (
|
||||
<Field label="Neuer Bienenstand" required>
|
||||
{({ id }) => (
|
||||
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue placeholder="Bienenstand wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map((apiary) => (
|
||||
<SelectItem key={apiary.id} value={apiary.id}>
|
||||
{apiary.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!apiaryId || mutation.isPending}>
|
||||
Umsetzen
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<ActionFooter error={error}>
|
||||
<Button type="submit" size="touch" disabled={!apiaryId || mutation.isPending}>
|
||||
{mutation.isPending ? "Wird umgesetzt…" : "Umsetzen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ActionFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -121,18 +148,31 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY");
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => splitColony(colony.id, { code, colonyType }),
|
||||
onSuccess: () => {
|
||||
invalidateColony(queryClient, colony.id);
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
toast.success(`Ableger ${code} angelegt`);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Ableger konnte nicht angelegt werden")),
|
||||
onError: (mutationError) =>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
@@ -145,46 +185,51 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ableger bilden</DialogTitle>
|
||||
<DialogDescription>
|
||||
Aus {colony.code} entsteht ein neues Volk. {colony.code} bleibt bestehen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="split-code">Code des Ablegers</Label>
|
||||
<Input
|
||||
id="split-code"
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="split-type">Volkstyp</Label>
|
||||
<Select
|
||||
value={colonyType}
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
>
|
||||
<SelectTrigger id="split-type">
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{COLONY_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Ableger anlegen
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<Field label="Code des Ablegers" required error={codeError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={code}
|
||||
onChange={(event) => {
|
||||
setCode(event.target.value);
|
||||
setCodeError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Volkstyp" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={colonyType}
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{COLONY_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<ActionFooter error={error}>
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ActionFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -195,6 +240,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queenId, setQueenId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: queens } = useQuery({ queryKey: ["queens"], queryFn: () => fetchQueens() });
|
||||
const currentQueen = queens?.find((queen) => queen.id === colony.currentQueenId);
|
||||
|
||||
@@ -205,8 +251,8 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Königin konnte nicht zugewiesen werden")),
|
||||
onError: (mutationError) =>
|
||||
setError(errorMessage(mutationError, "Königin konnte nicht zugewiesen werden.")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -226,52 +272,66 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assign-queen">Königin</Label>
|
||||
<Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}>
|
||||
<SelectTrigger id="assign-queen">
|
||||
<SelectValue placeholder="Königin wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(queens ?? [])
|
||||
.filter((queen) => queen.status === "ACTIVE")
|
||||
.map((queen) => (
|
||||
<SelectItem key={queen.id} value={queen.id}>
|
||||
{queen.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!queenId || mutation.isPending}>
|
||||
<Field label="Königin" required>
|
||||
{({ id }) => (
|
||||
<Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue placeholder="Königin wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(queens ?? [])
|
||||
.filter((queen) => queen.status === "ACTIVE")
|
||||
.map((queen) => (
|
||||
<SelectItem key={queen.id} value={queen.id}>
|
||||
{queen.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<ActionFooter error={error}>
|
||||
<Button type="submit" size="touch" disabled={!queenId || mutation.isPending}>
|
||||
Zuweisen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ActionFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mergedColonyId, setMergedColonyId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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({
|
||||
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }),
|
||||
onSuccess: () => {
|
||||
invalidateColony(queryClient, colony.id);
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
toast.success(
|
||||
merged ? `${merged.code} ist jetzt Teil von ${colony.code}` : "Völker vereinigt",
|
||||
);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Völker konnten nicht vereinigt werden")),
|
||||
onError: (mutationError) =>
|
||||
setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -286,40 +346,50 @@ function MergeDialog({ colony }: { colony: Colony }) {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Völker vereinigen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Das gewählte Volk geht in {colony.code} auf.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="merge-colony">Zu vereinigendes Volk</Label>
|
||||
<Select
|
||||
value={mergedColonyId}
|
||||
onValueChange={(value) => setMergedColonyId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="merge-colony">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(colonies ?? [])
|
||||
.filter((c) => c.id !== colony.id && c.status === "ACTIVE")
|
||||
.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.code}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field label="Welches Volk kommt dazu?" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={mergedColonyId}
|
||||
onValueChange={(value) => {
|
||||
setMergedColonyId(value ?? "");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((candidate) => (
|
||||
<SelectItem key={candidate.id} value={candidate.id}>
|
||||
{candidate.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!mergedColonyId || mutation.isPending}>
|
||||
Vereinigen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</SelectContent>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -335,7 +405,8 @@ function DissolveButton({ colony }: { colony: Colony }) {
|
||||
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 (
|
||||
@@ -359,12 +430,14 @@ function ReactivateButton({ colony }: { colony: Colony }) {
|
||||
invalidateColony(queryClient, colony.id);
|
||||
toast.success("Volk reaktiviert");
|
||||
},
|
||||
onError: () => toast.error("Volk konnte nicht reaktiviert werden"),
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Volk konnte nicht reaktiviert werden.")),
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
disabled={mutation.isPending}
|
||||
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";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateFeeding } from "@/lib/feedings/api";
|
||||
import { Feeding, FeedType } from "@/lib/feedings/types";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -19,6 +16,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,6 +25,18 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -38,6 +49,8 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
const [unit, setUnit] = useState(feeding.unit);
|
||||
const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? "");
|
||||
const [notes, setNotes] = useState(feeding.notes ?? "");
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -61,108 +74,150 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
}
|
||||
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>) {
|
||||
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();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fütterung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="feeding-edit-colony"
|
||||
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)}
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<ColonySelectField
|
||||
id="feeding-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-type">Futterart</Label>
|
||||
<Select
|
||||
value={feedType}
|
||||
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
|
||||
>
|
||||
<SelectTrigger id="feeding-edit-type" className="w-full">
|
||||
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FEED_TYPES.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{FEED_TYPE_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-amount">Menge</Label>
|
||||
<Input
|
||||
id="feeding-edit-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
setFieldErrors((current) => ({ ...current, date: undefined }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Was und wie viel?">
|
||||
<Field label="Futterart" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={feedType}
|
||||
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<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 className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-unit">Einheit</Label>
|
||||
<Input
|
||||
id="feeding-edit-unit"
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-product">Produkt</Label>
|
||||
<Input
|
||||
id="feeding-edit-product"
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="feeding-edit-batch"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="feeding-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
<Field label="Produkt">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={3}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={hasAnyValue(batchNumber, notes)}
|
||||
fieldCount={2}
|
||||
>
|
||||
<Field label="Chargennummer">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(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>
|
||||
</DialogContent>
|
||||
</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";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateInspection } from "@/lib/inspections/api";
|
||||
import { Inspection } from "@/lib/inspections/types";
|
||||
|
||||
const FEED_STATUS_LABELS: Record<string, string> = {
|
||||
ausreichend: "Ausreichend",
|
||||
niedrig: "Niedrig",
|
||||
kritisch: "Kritisch",
|
||||
};
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,304 +19,335 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(inspection.colonyId);
|
||||
const [date, setDate] = useState(inspection.date.slice(0, 10));
|
||||
const [weather, setWeather] = useState(inspection.weather ?? "");
|
||||
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
|
||||
const [colonyStrength, setColonyStrength] = useState(
|
||||
inspection.colonyStrength?.toString() ?? "",
|
||||
const [values, setValues] = useState<InspectionFormValues>(() =>
|
||||
valuesFromInspection(inspection),
|
||||
);
|
||||
const [broodAmount, setBroodAmount] = useState(inspection.broodAmount ?? "");
|
||||
const [broodPattern, setBroodPattern] = useState(inspection.broodPattern?.toString() ?? "");
|
||||
const [openBroodPresent, setOpenBroodPresent] = useState(inspection.openBroodPresent ?? false);
|
||||
const [cappedBroodPresent, setCappedBroodPresent] = useState(
|
||||
inspection.cappedBroodPresent ?? false,
|
||||
);
|
||||
const [queenSeen, setQueenSeen] = useState(inspection.queenSeen ?? false);
|
||||
const [eggsPresent, setEggsPresent] = useState(inspection.eggsPresent ?? true);
|
||||
const [queenCellsPresent, setQueenCellsPresent] = useState(
|
||||
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 [dateError, setDateError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof InspectionFormValues>(
|
||||
key: K,
|
||||
value: InspectionFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
if (key === "date") setDateError(null);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateInspection(inspection.id, {
|
||||
colonyId,
|
||||
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: () => {
|
||||
mutationFn: (input: InspectionFormValues) =>
|
||||
updateInspection(inspection.id, toInspectionInput(input)),
|
||||
onSuccess: (_result, input) => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// 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({
|
||||
queryKey: ["colonies", affectedColonyId, "inspections"],
|
||||
});
|
||||
}
|
||||
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>) {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="inspection-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<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)}
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<ColonySelectField
|
||||
id="inspection-edit-colony"
|
||||
value={values.colonyId}
|
||||
onChange={(colonyId) => set("colonyId", colonyId)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-weather">Wetter</Label>
|
||||
<Input
|
||||
id="inspection-edit-weather"
|
||||
value={weather}
|
||||
onChange={(event) => setWeather(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-temperature">Temperatur (°C)</Label>
|
||||
<Input
|
||||
id="inspection-edit-temperature"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label>
|
||||
<Input
|
||||
id="inspection-edit-strength"
|
||||
type="number"
|
||||
value={colonyStrength}
|
||||
onChange={(event) => setColonyStrength(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-brood-amount">Brutmenge</Label>
|
||||
<Input
|
||||
id="inspection-edit-brood-amount"
|
||||
value={broodAmount}
|
||||
onChange={(event) => setBroodAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-brood-pattern">Brutbild (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-edit-brood-pattern"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={broodPattern}
|
||||
onChange={(event) => setBroodPattern(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-open-brood">
|
||||
<input
|
||||
id="inspection-edit-open-brood"
|
||||
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"
|
||||
<Field label="Datum" required error={dateError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.date}
|
||||
onChange={(event) => set("date", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="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>
|
||||
<Field label="Brutbild">
|
||||
{({ id, labelId, describedBy }) => (
|
||||
<RatingScale
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
describedBy={describedBy}
|
||||
value={values.broodPattern}
|
||||
onChange={(rating) => set("broodPattern", rating)}
|
||||
lowLabel="lückenhaft"
|
||||
highLabel="geschlossen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Brutmenge" hint="z. B. „4 Waben“">
|
||||
{({ id, describedBy }) => (
|
||||
<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
|
||||
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
|
||||
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>
|
||||
<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"
|
||||
inputMode="decimal"
|
||||
className="h-11"
|
||||
value={values.temperature}
|
||||
onChange={(event) => set("temperature", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Honigraum">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.honeySuperStatus}
|
||||
onChange={(event) => set("honeySuperStatus", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Varroa-Befund">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.varroaIssues}
|
||||
onChange={(event) => set("varroaIssues", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Gesundheitsauffälligkeiten">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.healthIssues}
|
||||
onChange={(event) => set("healthIssues", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Maßnahmen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.measures}
|
||||
onChange={(event) => set("measures", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateSwarmControlCheck } from "@/lib/swarm-control/api";
|
||||
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -19,6 +16,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DATE_REQUIRED_MESSAGE,
|
||||
DialogSubmitBar,
|
||||
EditTriggerButton,
|
||||
hasAnyValue,
|
||||
} from "./edit-dialog-parts";
|
||||
|
||||
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -38,6 +44,8 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
const [actionTaken, setActionTaken] = useState<SwarmControlAction>(check.actionTaken);
|
||||
const [nextCheckDate, setNextCheckDate] = useState(check.nextCheckDate?.slice(0, 10) ?? "");
|
||||
const [notes, setNotes] = useState(check.notes ?? "");
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -61,124 +69,144 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
}
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
setSubmitError(null);
|
||||
if (!date) {
|
||||
setDateError(DATE_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="swarm-edit-colony"
|
||||
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)}
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<ColonySelectField
|
||||
id="swarm-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-cells-found">Weiselzellen gefunden</Label>
|
||||
<Input
|
||||
id="swarm-edit-cells-found"
|
||||
type="number"
|
||||
min={0}
|
||||
value={queenCellsFound}
|
||||
onChange={(event) => setQueenCellsFound(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="swarm-edit-cells-capped"
|
||||
>
|
||||
<input
|
||||
id="swarm-edit-cells-capped"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
<Field label="Datum" required error={dateError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
setDateError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<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}
|
||||
onChange={(event) => setQueenCellsCapped(event.target.checked)}
|
||||
onChange={setQueenCellsCapped}
|
||||
/>
|
||||
Weiselzellen bereits verdeckelt (akut)
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-mood">Schwarmstimmung</Label>
|
||||
<Select
|
||||
value={swarmMood}
|
||||
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
|
||||
>
|
||||
<SelectTrigger id="swarm-edit-mood" className="w-full">
|
||||
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SWARM_MOOD_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-action">Maßnahme</Label>
|
||||
<Select
|
||||
value={actionTaken}
|
||||
onValueChange={(value) =>
|
||||
setActionTaken((value ?? actionTaken) as SwarmControlAction)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="swarm-edit-action" className="w-full">
|
||||
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SWARM_ACTION_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-next-check">Nächste Kontrolle</Label>
|
||||
<Input
|
||||
id="swarm-edit-next-check"
|
||||
type="date"
|
||||
value={nextCheckDate}
|
||||
onChange={(event) => setNextCheckDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="swarm-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
<Field label="Schwarmstimmung" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={swarmMood}
|
||||
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SWARM_MOOD_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={3} title="Maßnahme">
|
||||
<Field label="Maßnahme" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={actionTaken}
|
||||
onValueChange={(value) =>
|
||||
setActionTaken((value ?? actionTaken) as SwarmControlAction)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SWARM_ACTION_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Nächste Kontrolle">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={nextCheckDate}
|
||||
onChange={(event) => setNextCheckDate(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={4}
|
||||
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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateTreatment } from "@/lib/treatments/api";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,6 +17,17 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -39,6 +49,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
|
||||
treatment.withdrawalPeriodDays?.toString() ?? "",
|
||||
);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -69,168 +81,221 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
}
|
||||
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>) {
|
||||
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();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Behandlung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="treatment-edit-colony"
|
||||
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)}
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<ColonySelectField
|
||||
id="treatment-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
</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
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-active-ingredient">Wirkstoff</Label>
|
||||
<Input
|
||||
id="treatment-edit-active-ingredient"
|
||||
value={activeIngredient}
|
||||
onChange={(event) => setActiveIngredient(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-amount">Menge</Label>
|
||||
<Input
|
||||
id="treatment-edit-amount"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-method">Methode</Label>
|
||||
<Input
|
||||
id="treatment-edit-method"
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-reason">Grund</Label>
|
||||
<Input
|
||||
id="treatment-edit-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="treatment-edit-batch"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label>
|
||||
<Input
|
||||
id="treatment-edit-withdrawal"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={withdrawalPeriodDays}
|
||||
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
|
||||
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-supplier">Lieferant (Name, Anschrift)</Label>
|
||||
<Input
|
||||
id="treatment-edit-supplier"
|
||||
className="h-11"
|
||||
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
|
||||
value={supplier}
|
||||
onChange={(event) => setSupplier(event.target.value)}
|
||||
/>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
setFieldErrors((current) => ({ ...current, date: undefined }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Mittel">
|
||||
<Field label="Mittel" required error={fieldErrors.product}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
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}
|
||||
onChange={(event) => setActiveIngredient(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Menge">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Methode">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Grund">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={3} title="Zeitraum & Wartezeit">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Beginn">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Ende">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-proof">Beleg für den Erwerb</Label>
|
||||
<Input
|
||||
id="treatment-edit-proof"
|
||||
className="h-11"
|
||||
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
|
||||
value={purchaseProof}
|
||||
onChange={(event) => setPurchaseProof(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
|
||||
<Input
|
||||
id="treatment-edit-vet"
|
||||
className="h-11"
|
||||
value={prescribingVet}
|
||||
onChange={(event) => setPrescribingVet(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="treatment-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
<Field label="Wartezeit (Tage)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={withdrawalPeriodDays}
|
||||
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={4}
|
||||
title="Für das Bestandsbuch"
|
||||
description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
|
||||
>
|
||||
<Field label="Chargennummer">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
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"
|
||||
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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
@@ -19,6 +16,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,6 +25,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DATE_REQUIRED_MESSAGE,
|
||||
DialogSubmitBar,
|
||||
EditTriggerButton,
|
||||
hasAnyValue,
|
||||
} from "./edit-dialog-parts";
|
||||
|
||||
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -42,6 +47,8 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
);
|
||||
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
|
||||
const [notes, setNotes] = useState(measurement.notes ?? "");
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -65,111 +72,142 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
}
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
setSubmitError(null);
|
||||
if (!date) {
|
||||
setDateError(DATE_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger render={<EditTriggerButton />} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="varroa-edit-colony"
|
||||
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)}
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<ColonySelectField
|
||||
id="varroa-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-method">Methode</Label>
|
||||
<Select
|
||||
value={method}
|
||||
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
|
||||
>
|
||||
<SelectTrigger id="varroa-edit-method" className="w-full">
|
||||
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VARROA_METHODS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{VARROA_METHOD_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-count">Milbenzahl</Label>
|
||||
<Input
|
||||
id="varroa-edit-count"
|
||||
type="number"
|
||||
min={0}
|
||||
value={miteCount}
|
||||
onChange={(event) => setMiteCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-duration">Zeitraum (Minuten)</Label>
|
||||
<Input
|
||||
id="varroa-edit-duration"
|
||||
type="number"
|
||||
min={0}
|
||||
value={durationMinutes}
|
||||
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label>
|
||||
<Input
|
||||
id="varroa-edit-calculated"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
value={calculatedValue}
|
||||
onChange={(event) => setCalculatedValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-assessment">Einschätzung</Label>
|
||||
<Input
|
||||
id="varroa-edit-assessment"
|
||||
value={assessment}
|
||||
onChange={(event) => setAssessment(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="varroa-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
<Field label="Datum" required error={dateError}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={date}
|
||||
onChange={(event) => {
|
||||
setDate(event.target.value);
|
||||
setDateError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Messung">
|
||||
<Field label="Methode" required>
|
||||
{({ id }) => (
|
||||
<Select
|
||||
value={method}
|
||||
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
|
||||
>
|
||||
<SelectTrigger id={id} className="h-11 w-full">
|
||||
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VARROA_METHODS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{VARROA_METHOD_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Milbenzahl">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={miteCount}
|
||||
onChange={(event) => setMiteCount(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Zeitraum (Minuten)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={durationMinutes}
|
||||
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Milben pro Tag">
|
||||
{({ 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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in new issue
Block a user