feat: make the parent relation of every record correctable after entry

An inspection, treatment, feeding, varroa measurement or swarm control check
filed against the wrong colony could not be repaired: the colony was frozen at
creation time, and with no delete endpoint either, the only remedy was leaving
a wrong record in the colony's history. The same held for a harvest booked
against the wrong apiary or with a colony forgotten, for an extraction linked
to the wrong harvests, and for a honey batch whose number or source extraction
was mistyped.

Backend:
- The five colony-scoped update DTOs now cover the full create DTO, colonyId
  included.
- Harvest update accepts apiaryId and colonyIds, extraction update accepts
  harvestIds. Both validate the ids first and replace the whole join-row set
  inside the existing transaction; the join rows carry no data of their own,
  so replacing beats diffing.
- Honey batch update accepts batchNumber, extractionId and status. Status was
  previously only ever set to BOTTLED by the bottling module, so SOLD and
  CONSUMED were unreachable through the API.
- Colony and storage container codes, and a task's relatedType/relatedId, are
  editable.
- apiaryId on a colony and storageContainerId on a honey batch stay out of
  PATCH on purpose: both have dedicated endpoints that also write the history
  entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain
  depends on.
- AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a
  404. Services write straight against the foreign keys with no application
  level existence check, so before this a stale or mistyped relation id came
  back as a bare 500 and filled the log with fake server errors.

Frontend:
- New shared fields: ColonySelectField (used by all five colony-scoped edit
  dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField.
- Re-filing a record invalidates the lists of both the colony it left and the
  one it joined, otherwise one of the two stays stale.
- The harvest dialog gained an apiary select and a colony checklist that keeps
  already-linked colonies visible even when they moved or were dissolved; the
  honey batch dialog gained batch number, source extraction and status.
- Extraction and harvest forms refuse to submit an empty relation set instead
  of letting the backend reject 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:01:05 +02:00
1 parent 2b15795a60
commit bee2199647
44 files changed
+958 -156

No files matched your search

@@ -76,6 +76,7 @@ function ConditionSelect({
export function ColonyEditCard({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [code, setCode] = useState(colony.code);
const [status, setStatus] = useState<ColonyStatus>(colony.status);
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
@@ -95,6 +96,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
const updateMutation = useMutation({
mutationFn: () =>
updateColony(colony.id, {
code,
status,
colonyType,
hiveType: hiveType || undefined,
@@ -133,6 +135,15 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-code">Volksnummer</Label>
<Input
id="colony-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -29,6 +30,7 @@ import {
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(feeding.colonyId);
const [date, setDate] = useState(feeding.date.slice(0, 10));
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
const [product, setProduct] = useState(feeding.product ?? "");
@@ -40,6 +42,7 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const mutation = useMutation({
mutationFn: () =>
updateFeeding(feeding.id, {
colonyId,
date: new Date(date).toISOString(),
feedType,
product: product || undefined,
@@ -49,9 +52,13 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", feeding.colonyId, "feedings"],
});
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([feeding.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "feedings"],
});
}
setOpen(false);
},
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"),
@@ -77,6 +84,11 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
<DialogTitle>Fütterung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="feeding-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-date">Datum</Label>
<Input
@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -34,6 +35,7 @@ import {
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(inspection.colonyId);
const [date, setDate] = useState(inspection.date.slice(0, 10));
const [weather, setWeather] = useState(inspection.weather ?? "");
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
@@ -64,6 +66,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
const mutation = useMutation({
mutationFn: () =>
updateInspection(inspection.id, {
colonyId,
date: new Date(date).toISOString(),
weather: weather || undefined,
temperature: temperature ? Number(temperature) : undefined,
@@ -86,9 +89,13 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
});
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([inspection.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "inspections"],
});
}
setOpen(false);
},
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"),
@@ -114,6 +121,11 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="inspection-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-date">Datum</Label>
<Input
@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -29,6 +30,7 @@ import {
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(check.colonyId);
const [date, setDate] = useState(check.date.slice(0, 10));
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
@@ -40,6 +42,7 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
const mutation = useMutation({
mutationFn: () =>
updateSwarmControlCheck(check.id, {
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
@@ -49,9 +52,13 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([check.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "swarm-control-checks"],
});
}
setOpen(false);
},
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"),
@@ -77,6 +84,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="swarm-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-date">Datum</Label>
<Input
@@ -10,6 +10,7 @@ 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 { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -21,6 +22,7 @@ import {
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(treatment.colonyId);
const [date, setDate] = useState(treatment.date.slice(0, 10));
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
@@ -38,6 +40,7 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const mutation = useMutation({
mutationFn: () =>
updateTreatment(treatment.id, {
colonyId,
date: new Date(date).toISOString(),
startDate: startDate ? new Date(startDate).toISOString() : undefined,
endDate: endDate ? new Date(endDate).toISOString() : undefined,
@@ -51,9 +54,13 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
});
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([treatment.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "treatments"],
});
}
setOpen(false);
},
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"),
@@ -79,6 +86,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
<DialogTitle>Behandlung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="treatment-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-date">Datum</Label>
<Input
@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -29,6 +30,7 @@ import {
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(measurement.colonyId);
const [date, setDate] = useState(measurement.date.slice(0, 10));
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
@@ -44,6 +46,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const mutation = useMutation({
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
colonyId,
date: new Date(date).toISOString(),
method,
miteCount: miteCount ? Number(miteCount) : undefined,
@@ -53,9 +56,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([measurement.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", affectedColonyId, "varroa-measurements"],
});
}
setOpen(false);
},
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"),
@@ -81,6 +88,11 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="varroa-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-date">Datum</Label>
<Input
@@ -10,6 +10,7 @@ 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 { HarvestSelectField } from "@/components/harvest-select-field";
import {
Dialog,
DialogContent,
@@ -21,6 +22,9 @@ import {
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [harvestIds, setHarvestIds] = useState<string[]>(
extraction.harvests.map((entry) => entry.harvestId),
);
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
const [room, setRoom] = useState(extraction.room ?? "");
@@ -31,6 +35,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
@@ -42,11 +47,16 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
setOpen(false);
},
onError: () => toast.error("Schleuderung konnte nicht gespeichert werden"),
onError: (error: Error) =>
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (harvestIds.length === 0) {
toast.error("Bitte mindestens eine Ernte auswählen.");
return;
}
mutation.mutate();
}
@@ -65,6 +75,11 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<HarvestSelectField
idPrefix="extraction-edit-harvest"
value={harvestIds}
onChange={setHarvestIds}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
@@ -2,14 +2,23 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHarvest } from "@/lib/harvests/api";
import { Harvest } from "@/lib/harvests/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -21,6 +30,10 @@ import {
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState(harvest.apiaryId);
const [colonyIds, setColonyIds] = useState<string[]>(
harvest.colonies.map((entry) => entry.colonyId),
);
const [date, setDate] = useState(harvest.date.slice(0, 10));
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
const [grossWeight, setGrossWeight] = useState(harvest.grossWeight?.toString() ?? "");
@@ -30,9 +43,35 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
);
const [notes, setNotes] = useState(harvest.notes ?? "");
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
// Colonies of the chosen apiary, plus any colony already on this harvest -
// one that was meanwhile moved or dissolved must stay visible, otherwise
// saving would silently drop it from the harvest.
const selectableColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId || colonyIds.includes(colony.id),
);
function toggleColony(colonyId: string, checked: boolean) {
setColonyIds((current) =>
checked
? [...current, colonyId]
: current.filter((id) => id !== colonyId),
);
}
const mutation = useMutation({
mutationFn: () =>
updateHarvest(harvest.id, {
apiaryId,
colonyIds,
date: new Date(date).toISOString(),
honeyType: honeyType || undefined,
grossWeight: grossWeight ? Number(grossWeight) : undefined,
@@ -41,14 +80,19 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["harvests", harvest.id] });
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
setOpen(false);
},
onError: () => toast.error("Ernte konnte nicht gespeichert werden"),
onError: (error: Error) =>
toast.error(error.message || "Ernte konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (colonyIds.length === 0) {
toast.error("Bitte mindestens ein Volk auswählen.");
return;
}
mutation.mutate();
}
@@ -67,6 +111,48 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
<DialogTitle>Ernte bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
<Select
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
>
<SelectTrigger id="harvest-edit-apiary" className="w-full">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
{selectableColonies.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Völker an diesem Bienenstand.
</p>
) : null}
{selectableColonies.map((colony) => (
<label
key={colony.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-edit-colony-${colony.id}`}
>
<input
id={`harvest-edit-colony-${colony.id}`}
type="checkbox"
className="size-5"
checked={colonyIds.includes(colony.id)}
onChange={(event) => toggleColony(colony.id, event.target.checked)}
/>
{colony.code}
</label>
))}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-date">Datum</Label>
<Input
@@ -10,6 +10,7 @@ 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 { HarvestSelectField } from "@/components/harvest-select-field";
import {
Dialog,
DialogContent,
@@ -28,6 +29,9 @@ function ExtractionEditForm({
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [harvestIds, setHarvestIds] = useState<string[]>(
extraction.harvests.map((entry) => entry.harvestId),
);
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
const [room, setRoom] = useState(extraction.room ?? "");
@@ -42,6 +46,7 @@ function ExtractionEditForm({
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
@@ -55,16 +60,26 @@ function ExtractionEditForm({
});
onSaved();
},
onError: () => toast.error("Schleuderung konnte nicht gespeichert werden"),
onError: (error: Error) =>
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (harvestIds.length === 0) {
toast.error("Bitte mindestens eine Ernte auswählen.");
return;
}
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<HarvestSelectField
idPrefix="batch-extraction-edit-harvest"
value={harvestIds}
onChange={setHarvestIds}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
@@ -2,13 +2,22 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -21,13 +30,24 @@ import {
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [batchNumber, setBatchNumber] = useState(honeyBatch.batchNumber);
const [extractionId, setExtractionId] = useState(honeyBatch.extractionId);
const [status, setStatus] = useState<HoneyBatchStatus>(honeyBatch.status);
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: () => fetchExtractions(),
});
const mutation = useMutation({
mutationFn: () =>
updateHoneyBatch(honeyBatch.id, {
batchNumber,
extractionId,
status,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
@@ -60,6 +80,54 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-number">Chargennummer</Label>
<Input
id="batch-edit-number"
required
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
<Select
value={extractionId}
onValueChange={(value) => setExtractionId(value ?? extractionId)}
>
<SelectTrigger id="batch-edit-extraction" className="w-full">
<SelectValue placeholder="Schleuderung wählen" />
</SelectTrigger>
<SelectContent>
{(extractions ?? []).map((extraction) => (
<SelectItem key={extraction.id} value={extraction.id}>
{new Date(extraction.date).toLocaleDateString("de-DE")}
{extraction.amount !== null ? ` · ${extraction.amount} kg` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-status">Status</Label>
<Select
value={status}
onValueChange={(value) => setStatus((value ?? status) as HoneyBatchStatus)}
>
<SelectTrigger id="batch-edit-status" className="w-full">
<SelectValue>{HONEY_BATCH_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(HONEY_BATCH_STATUS_LABELS) as HoneyBatchStatus[]
).map((option) => (
<SelectItem key={option} value={option}>
{HONEY_BATCH_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
<Input
@@ -21,6 +21,7 @@ import {
export function EditStorageContainerDialog({ container }: { container: StorageContainer }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [code, setCode] = useState(container.code);
const [containerType, setContainerType] = useState(container.containerType ?? "");
const [capacity, setCapacity] = useState(container.capacity?.toString() ?? "");
const [location, setLocation] = useState(container.location ?? "");
@@ -28,6 +29,7 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
const mutation = useMutation({
mutationFn: () =>
updateStorageContainer(container.id, {
code,
containerType: containerType || undefined,
capacity: capacity ? Number(capacity) : undefined,
location: location || undefined,
@@ -59,6 +61,15 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
<DialogTitle>Lagerbehälter bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="container-edit-code">Code</Label>
<Input
id="container-edit-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="container-edit-type">Typ</Label>
<Input
@@ -5,13 +5,14 @@ import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateTask } from "@/lib/tasks/api";
import { Task, TaskPriority } from "@/lib/tasks/types";
import { Task, TaskPriority, TaskRelatedType } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api";
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 { TaskRelatedField } from "@/components/task-related-field";
import {
Select,
SelectContent,
@@ -35,6 +36,8 @@ export function TaskEditDialog({ task }: { task: Task }) {
const [priority, setPriority] = useState<TaskPriority>(task.priority);
const [dueDate, setDueDate] = useState(task.dueDate?.slice(0, 10) ?? "");
const [assignedUserId, setAssignedUserId] = useState(task.assignedUserId ?? "");
const [relatedType, setRelatedType] = useState<TaskRelatedType>(task.relatedType);
const [relatedId, setRelatedId] = useState(task.relatedId ?? "");
// Assigning a task requires users:read, which not every role has - if the
// request fails, the assignee field below is simply omitted rather than
@@ -53,6 +56,8 @@ export function TaskEditDialog({ task }: { task: Task }) {
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
assignedUserId: assignedUserId || undefined,
relatedType,
relatedId: relatedType === "GENERAL" ? undefined : relatedId || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
@@ -133,6 +138,13 @@ export function TaskEditDialog({ task }: { task: Task }) {
</Select>
</div>
) : null}
<TaskRelatedField
idPrefix="task-edit-related"
relatedType={relatedType}
relatedId={relatedId}
onRelatedTypeChange={setRelatedType}
onRelatedIdChange={setRelatedId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="task-edit-due">Fällig am</Label>
<Input
@@ -0,0 +1,61 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchColonies } from "@/lib/colonies/api";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ColonySelectFieldProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
label?: string;
}
/**
* Picks the colony a record belongs to. Every colony-scoped record
* (inspection, treatment, feeding, varroa measurement, swarm control check)
* can be re-filed under a different colony after the fact, because filing one
* against the wrong colony is a data-entry error rather than a fact of
* beekeeping - and the only alternative repair would be deleting the record.
*
* Dissolved colonies stay in the list: a record already filed against one has
* to remain visible and selectable, otherwise opening the form would silently
* re-file it somewhere else.
*/
export function ColonySelectField({
id,
value,
onChange,
label = "Volk",
}: ColonySelectFieldProps) {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{(colonies ?? []).map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
{colony.status === "ACTIVE" ? "" : " (aufgelöst)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchHarvests } from "@/lib/harvests/api";
import { Label } from "@/components/ui/label";
interface HarvestSelectFieldProps {
idPrefix: string;
value: string[];
onChange: (harvestIds: string[]) => void;
}
/**
* Picks the harvests an extraction was made from. The set stays editable after
* the fact: an extraction linked to the wrong harvest breaks the traceability
* chain from colony to jar, and re-pointing it is the only repair that does
* not require deleting the extraction and everything downstream of it.
*/
export function HarvestSelectField({
idPrefix,
value,
onChange,
}: HarvestSelectFieldProps) {
const { data: harvests } = useQuery({
queryKey: ["harvests"],
queryFn: () => fetchHarvests(),
});
function toggle(harvestId: string, checked: boolean) {
onChange(
checked ? [...value, harvestId] : value.filter((id) => id !== harvestId),
);
}
return (
<div className="flex flex-col gap-2">
<Label>Ernten</Label>
{(harvests ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Ernten erfasst.</p>
) : null}
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
{(harvests ?? []).map((harvest) => (
<label
key={harvest.id}
className="flex min-h-11 items-center gap-3 text-sm"
htmlFor={`${idPrefix}-${harvest.id}`}
>
<input
id={`${idPrefix}-${harvest.id}`}
type="checkbox"
className="size-5 shrink-0"
checked={value.includes(harvest.id)}
onChange={(event) => toggle(harvest.id, event.target.checked)}
/>
<span>
{new Date(harvest.date).toLocaleDateString("de-DE")}
{harvest.netWeight !== null ? ` · ${harvest.netWeight} kg` : ""}
{harvest.colonies.length > 0
? ` · ${harvest.colonies.map((entry) => entry.colony.code).join(", ")}`
: ""}
</span>
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { TaskRelatedType } from "@/lib/tasks/types";
import {
TASK_RELATED_TYPES,
TASK_RELATED_TYPE_LABELS,
} from "@/lib/tasks/labels";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchQueens } from "@/lib/queens/api";
import { fetchStorageContainers } from "@/lib/storage/api";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface RelatedOption {
id: string;
label: string;
}
interface TaskRelatedFieldProps {
idPrefix: string;
relatedType: TaskRelatedType;
relatedId: string;
onRelatedTypeChange: (relatedType: TaskRelatedType) => void;
onRelatedIdChange: (relatedId: string) => void;
}
/**
* Picks what a task refers to. The reference is polymorphic, so the second
* select only loads the list matching the chosen type, and GENERAL has no
* record to point at at all.
*
* A task created from an inspection carries the reference automatically, but
* it stays editable: a task filed against the wrong colony sends a beekeeper
* to the wrong hive.
*/
export function TaskRelatedField({
idPrefix,
relatedType,
relatedId,
onRelatedTypeChange,
onRelatedIdChange,
}: TaskRelatedFieldProps) {
const colonies = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
enabled: relatedType === "COLONY",
});
const apiaries = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
enabled: relatedType === "APIARY",
});
const queens = useQuery({
queryKey: ["queens"],
queryFn: () => fetchQueens(),
enabled: relatedType === "QUEEN",
});
const honeyBatches = useQuery({
queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches(),
enabled: relatedType === "HONEY_BATCH",
});
const containers = useQuery({
queryKey: ["storage-containers"],
queryFn: () => fetchStorageContainers(),
enabled: relatedType === "STORAGE",
});
const optionsByType: Record<TaskRelatedType, RelatedOption[]> = {
COLONY: (colonies.data ?? []).map((colony) => ({
id: colony.id,
label: colony.code,
})),
APIARY: (apiaries.data ?? []).map((apiary) => ({
id: apiary.id,
label: apiary.name,
})),
QUEEN: (queens.data ?? []).map((queen) => ({
id: queen.id,
label: queen.code,
})),
HONEY_BATCH: (honeyBatches.data ?? []).map((honeyBatch) => ({
id: honeyBatch.id,
label: honeyBatch.batchNumber,
})),
STORAGE: (containers.data ?? []).map((container) => ({
id: container.id,
label: container.code,
})),
GENERAL: [],
};
const options = optionsByType[relatedType];
return (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`${idPrefix}-type`}>Bezug</Label>
<Select
value={relatedType}
onValueChange={(value) => {
const next = (value ?? relatedType) as TaskRelatedType;
onRelatedTypeChange(next);
// The old id belongs to the old type's records, so it would point
// at nothing after the switch.
onRelatedIdChange("");
}}
>
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
<SelectValue>{TASK_RELATED_TYPE_LABELS[relatedType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{TASK_RELATED_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{TASK_RELATED_TYPE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{relatedType === "GENERAL" ? null : (
<div className="flex flex-col gap-2">
<Label htmlFor={`${idPrefix}-record`}>
{TASK_RELATED_TYPE_LABELS[relatedType]}
</Label>
<Select
value={relatedId}
onValueChange={(value) => onRelatedIdChange(value ?? relatedId)}
>
<SelectTrigger id={`${idPrefix}-record`} className="w-full">
<SelectValue placeholder="Datensatz wählen" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
);
}
@@ -47,6 +47,7 @@ export interface CreateColonyInput {
}
export interface UpdateColonyInput {
code?: string;
hiveType?: string;
originText?: string;
colonyType?: ColonyType;
@@ -35,6 +35,8 @@ export interface CreateExtractionInput {
}
export interface UpdateExtractionInput {
/** Replaces the whole set of source harvests. */
harvestIds?: string[];
date?: string;
amount?: number;
room?: string;
@@ -31,6 +31,8 @@ export interface CreateFeedingInput {
}
export interface UpdateFeedingInput {
/** Re-files the feeding under a different colony. */
colonyId?: string;
date?: string;
feedType?: FeedType;
product?: string;
@@ -33,6 +33,9 @@ export interface CreateHarvestInput {
}
export interface UpdateHarvestInput {
apiaryId?: string;
/** Replaces the whole set of participating colonies. */
colonyIds?: string[];
date?: string;
honeyType?: string;
honeySuperCount?: number;
@@ -90,9 +90,12 @@ export interface CreateHoneyBatchInput {
}
export interface UpdateHoneyBatchInput {
batchNumber?: string;
extractionId?: string;
honeyType?: string;
amount?: number;
waterContent?: number;
status?: HoneyBatchStatus;
}
export interface CreateHoneyMeasurementInput {
@@ -50,6 +50,8 @@ export interface CreateInspectionInput {
}
export interface UpdateInspectionInput {
/** Re-files the inspection under a different colony. */
colonyId?: string;
date?: string;
weather?: string;
temperature?: number;
@@ -42,6 +42,7 @@ export interface CreateStorageContainerInput {
}
export interface UpdateStorageContainerInput {
code?: string;
containerType?: string;
capacity?: number;
location?: string;
@@ -32,6 +32,8 @@ export interface CreateSwarmControlCheckInput {
}
export interface UpdateSwarmControlCheckInput {
/** Re-files the check under a different colony. */
colonyId?: string;
date?: string;
queenCellsFound?: number;
queenCellsCapped?: boolean;
+19 -1
View File
@@ -1,4 +1,4 @@
import { TaskPriority, TaskStatus } from "./types";
import { TaskPriority, TaskRelatedType, TaskStatus } from "./types";
export const TASK_STATUSES: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
@@ -17,3 +17,21 @@ export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> = {
HIGH: "Hoch",
URGENT: "Dringend",
};
export const TASK_RELATED_TYPES: TaskRelatedType[] = [
"GENERAL",
"COLONY",
"APIARY",
"QUEEN",
"HONEY_BATCH",
"STORAGE",
];
export const TASK_RELATED_TYPE_LABELS: Record<TaskRelatedType, string> = {
GENERAL: "Allgemein",
COLONY: "Volk",
APIARY: "Bienenstand",
QUEEN: "Königin",
HONEY_BATCH: "Honigcharge",
STORAGE: "Lagerbehälter",
};
+2
View File
@@ -44,4 +44,6 @@ export interface UpdateTaskInput {
dueDate?: string;
assignedUserId?: string;
status?: TaskStatus;
relatedType?: TaskRelatedType;
relatedId?: string;
}
@@ -32,6 +32,8 @@ export interface CreateTreatmentInput {
}
export interface UpdateTreatmentInput {
/** Re-files the treatment under a different colony. */
colonyId?: string;
date?: string;
startDate?: string;
endDate?: string;
+2
View File
@@ -30,6 +30,8 @@ export interface CreateVarroaMeasurementInput {
}
export interface UpdateVarroaMeasurementInput {
/** Re-files the measurement under a different colony. */
colonyId?: string;
date?: string;
method?: VarroaMethod;
durationMinutes?: number;