feat: allow correcting honey measurements and queen evaluations
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s

Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 08:15:54 +02:00
1 parent b0e05daa42
commit 42ceb33844
14 files changed
+503 -15

No files matched your search

@@ -0,0 +1,175 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyMeasurement } from "@/lib/honey-batches/api";
import { HoneyMeasurement } from "@/lib/honey-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Corrects a water-content reading. The figure decides whether a batch may be
* sold as honey at all, so a slipped digit left standing is worse than a
* reading that was never recorded.
*/
function EditMeasurementForm({
measurement,
honeyBatchId,
onSaved,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [waterContent, setWaterContent] = useState(
measurement.waterContent.toString(),
);
const [measuredAt, setMeasuredAt] = useState(
measurement.measuredAt.slice(0, 10),
);
const [device, setDevice] = useState(measurement.device ?? "");
const [temperature, setTemperature] = useState(
measurement.temperature?.toString() ?? "",
);
const [notes, setNotes] = useState(measurement.notes ?? "");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateHoneyMeasurement(measurement.id, {
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatchId],
});
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Messung konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-water">Wassergehalt (%)</Label>
<Input
id="measurement-edit-water"
type="number"
step="0.1"
min={0}
max={100}
required
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-date">Gemessen am</Label>
<Input
id="measurement-edit-date"
type="date"
required
value={measuredAt}
onChange={(event) => setMeasuredAt(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-device">Gerät</Label>
<Input
id="measurement-edit-device"
value={device}
onChange={(event) => setDevice(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-temp">Temperatur (°C)</Label>
<Input
id="measurement-edit-temp"
type="number"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-notes">Notizen</Label>
<Textarea
id="measurement-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
export function EditMeasurementDialog({
measurement,
honeyBatchId,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Messung bearbeiten</DialogTitle>
</DialogHeader>
{open ? (
<EditMeasurementForm
measurement={measurement}
honeyBatchId={honeyBatchId}
onSaved={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
import { EditMeasurementDialog } from "./edit-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
@@ -106,13 +107,21 @@ export default function HoneyBatchDetailPage() {
) : null}
{honeyBatch.measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? (
<p className="text-muted-foreground">{measurement.device}</p>
) : null}
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? (
<p className="text-muted-foreground">{measurement.device}</p>
) : null}
</div>
<EditMeasurementDialog
measurement={measurement}
honeyBatchId={honeyBatch.id}
/>
</div>
</div>
))}
</CardContent>
@@ -1,10 +1,14 @@
"use client";
import { FormEvent, useState } from "react";
import { Star } from "lucide-react";
import { Pencil, Star } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { addQueenEvaluation, fetchQueenEvaluations } from "@/lib/queens/api";
import { CreateQueenEvaluationInput } from "@/lib/queens/types";
import {
addQueenEvaluation,
fetchQueenEvaluations,
updateQueenEvaluation,
} from "@/lib/queens/api";
import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -32,6 +36,122 @@ function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Corrects a rating after the fact. Breeding decisions are made from these
* scores, so a slipped digit has to be fixable rather than left standing.
*/
function EditEvaluationDialog({
evaluation,
queenId,
}: {
evaluation: QueenEvaluation;
queenId: string;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(evaluation.date.slice(0, 10));
const [scores, setScores] = useState<Record<string, string>>(
Object.fromEntries(
CRITERIA.map((criterion) => [
criterion.key,
String(evaluation[criterion.key]),
]),
),
);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateQueenEvaluation(evaluation.id, {
date: new Date(date).toISOString(),
colonyStrength: Number(scores.colonyStrength),
gentleness: Number(scores.gentleness),
combBuild: Number(scores.combBuild),
swarmingTendency: Number(scores.swarmingTendency),
broodPattern: Number(scores.broodPattern),
honeyYield: Number(scores.honeyYield),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["queens", queenId, "evaluations"],
});
setOpen(false);
setError(null);
},
onError: (mutationError: Error) =>
setError(
mutationError.message || "Bewertung konnte nicht gespeichert werden.",
),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Bewertung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-date-${evaluation.id}`}>Datum</Label>
<Input
id={`evaluation-edit-date-${evaluation.id}`}
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
{CRITERIA.map((criterion) => (
<div key={criterion.key} className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-${criterion.key}-${evaluation.id}`}>
{criterion.label} (1–5)
</Label>
<Input
id={`evaluation-edit-${criterion.key}-${evaluation.id}`}
type="number"
min={1}
max={5}
required
value={scores[criterion.key]}
onChange={(event) =>
setScores((prev) => ({
...prev,
[criterion.key]: event.target.value,
}))
}
/>
</div>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -141,9 +261,12 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
) : null}
{evaluations?.map((evaluation) => (
<div key={evaluation.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(evaluation.date).toLocaleDateString("de-DE")}
</p>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
{new Date(evaluation.date).toLocaleDateString("de-DE")}
</p>
<EditEvaluationDialog evaluation={evaluation} queenId={queenId} />
</div>
<p className="text-muted-foreground">
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
UpdateHoneyMeasurementInput,
HoneyBatch,
HoneyBatchContainerAllocation,
HoneyBatchRelocation,
@@ -47,6 +48,16 @@ export function updateHoneyBatch(
});
}
export function updateHoneyMeasurement(
measurementId: string,
input: UpdateHoneyMeasurementInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/measurements/${measurementId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addHoneyMeasurement(
id: string,
input: CreateHoneyMeasurementInput,
@@ -107,3 +107,11 @@ export interface CreateHoneyMeasurementInput {
temperature?: number;
notes?: string;
}
export interface UpdateHoneyMeasurementInput {
waterContent?: number;
measuredAt?: string;
device?: string;
temperature?: number;
notes?: string;
}
+11
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import {
AssignColonyInput,
CreateQueenEvaluationInput,
UpdateQueenEvaluationInput,
CreateQueenInput,
Queen,
QueenColonyHistoryEntry,
@@ -59,6 +60,16 @@ export function fetchQueenEvaluations(id: string): Promise<QueenEvaluation[]> {
return apiFetch<QueenEvaluation[]>(`/queens/${id}/evaluations`);
}
export function updateQueenEvaluation(
evaluationId: string,
input: UpdateQueenEvaluationInput,
): Promise<QueenEvaluation> {
return apiFetch<QueenEvaluation>(`/queens/evaluations/${evaluationId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addQueenEvaluation(
id: string,
input: CreateQueenEvaluationInput,
+2
View File
@@ -124,3 +124,5 @@ export interface CreateQueenEvaluationInput {
honeyYield: number;
notes?: string;
}
export type UpdateQueenEvaluationInput = Partial<CreateQueenEvaluationInput>;