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:
1 parent
2b15795a60
commit
bee2199647
44 files changed
+958
-156
No files matched your search
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user