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