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

@@ -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>
)}
</>
);
}