feat: allow a honey batch to be split across multiple storage containers
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Successful in 2m45s

A batch that doesn't fit in one container (e.g. 125 kg across 5 x 25 kg
buckets) had no way to be represented - HoneyBatch.storageContainerId
is a single nullable FK. Adds HoneyBatchContainerAllocation as an
additive join table (container + amount per batch), independent of the
existing single-container/relocate() flow which still covers the
simple one-container case unchanged.

- GET/PUT/DELETE /honey-batches/:id/containers[/:containerId], reusing
  the existing honey-batches:relocate permission.
- Assigning a container is an upsert (@@unique on batch+container), and
  the total assigned is validated against the batch's own amount when
  known.
- New "Aufteilung auf mehrere Behälter" card on the honey batch detail
  page, alongside (not replacing) the existing storage card.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:44:33 +02:00
1 parent c91dc9ebf1
commit 1054a845ef
10 files changed
+579 -7

No files matched your search

@@ -0,0 +1,190 @@
"use client";
import { FormEvent, useState } from "react";
import { Trash2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchHoneyBatchContainerAllocations,
removeHoneyBatchContainerAllocation,
setHoneyBatchContainerAllocation,
} from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/empty-state";
import { Boxes } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Additive to HoneyBatchStorageCard's single-container "Umlagern" flow above
// it on the page - covers a batch too large for one container (e.g. 125 kg
// split across 5 x 25 kg buckets), which a single storageContainerId can't
// represent.
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const [storageContainerId, setStorageContainerId] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: containers } = useQuery({
queryKey: ["storage-containers"],
queryFn: fetchStorageContainers,
});
const {
data: allocations,
isLoading,
} = useQuery({
queryKey: ["honey-batches", honeyBatch.id, "containers"],
queryFn: () => fetchHoneyBatchContainerAllocations(honeyBatch.id),
});
const assignMutation = useMutation({
mutationFn: (input: { storageContainerId: string; amount: number }) =>
setHoneyBatchContainerAllocation(honeyBatch.id, input.storageContainerId, input.amount),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatch.id, "containers"],
});
setStorageContainerId("");
setAmount("");
setError(null);
},
onError: () => {
setError("Zuweisung konnte nicht gespeichert werden. Übersteigt die Menge die Charge?");
},
});
const removeMutation = useMutation({
mutationFn: (containerId: string) =>
removeHoneyBatchContainerAllocation(honeyBatch.id, containerId),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatch.id, "containers"],
});
toast.success("Zuweisung entfernt");
},
onError: () => toast.error("Zuweisung konnte nicht entfernt werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!storageContainerId) {
setError("Bitte einen Lagerbehälter auswählen.");
return;
}
const parsedAmount = Number(amount);
if (!amount || parsedAmount <= 0) {
setError("Bitte eine Menge größer als 0 angeben.");
return;
}
assignMutation.mutate({ storageContainerId, amount: parsedAmount });
}
const totalAllocated = (allocations ?? []).reduce((sum, a) => sum + a.amount, 0);
return (
<Card>
<CardHeader>
<CardTitle>Aufteilung auf mehrere Behälter</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">
Für Chargen, die nicht in einen einzigen Behälter passen (z. B. 125 kg auf 5 Eimer à 25
kg). Unabhängig vom Lagerbehälter oben.
</p>
{honeyBatch.amount !== null ? (
<p className="text-muted-foreground">
Zugewiesen: {totalAllocated} kg von {honeyBatch.amount} kg
</p>
) : null}
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!allocations || allocations.length === 0) ? (
<EmptyState
icon={Boxes}
title="Noch keine Aufteilung"
description="Weise dieser Charge unten mehrere Behälter mit jeweiliger Menge zu."
/>
) : null}
{allocations?.map((allocation) => (
<div
key={allocation.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div>
<p className="font-medium">
{allocation.storageContainer.code}
{allocation.storageContainer.location
? ` · ${allocation.storageContainer.location}`
: ""}
</p>
<p className="text-muted-foreground">{allocation.amount} kg</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate(allocation.storageContainerId)}
aria-label="Zuweisung entfernen"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="allocation-container">Behälter</Label>
<Select
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="allocation-container">
<SelectValue placeholder="Lagerbehälter wählen" />
</SelectTrigger>
<SelectContent>
{containers?.map((container) => (
<SelectItem key={container.id} value={container.id}>
{container.code}
{container.location ? ` · ${container.location}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="allocation-amount">Menge (kg)</Label>
<Input
id="allocation-amount"
type="number"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -11,6 +11,7 @@ import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { EmptyState } from "@/components/empty-state";
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -83,6 +84,7 @@ export default function HoneyBatchDetailPage() {
</CardContent>
</Card>
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
<HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} />
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
@@ -3,6 +3,7 @@ import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
HoneyBatch,
HoneyBatchContainerAllocation,
HoneyBatchRelocation,
HoneyBatchStatus,
RelocateHoneyBatchInput,
@@ -69,3 +70,29 @@ export function relocateHoneyBatch(
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
}
export function fetchHoneyBatchContainerAllocations(
id: string,
): Promise<HoneyBatchContainerAllocation[]> {
return apiFetch<HoneyBatchContainerAllocation[]>(`/honey-batches/${id}/containers`);
}
export function setHoneyBatchContainerAllocation(
id: string,
containerId: string,
amount: number,
): Promise<HoneyBatchContainerAllocation> {
return apiFetch<HoneyBatchContainerAllocation>(
`/honey-batches/${id}/containers/${containerId}`,
{ method: "PUT", body: JSON.stringify({ amount }) },
);
}
export function removeHoneyBatchContainerAllocation(
id: string,
containerId: string,
): Promise<void> {
return apiFetch<void>(`/honey-batches/${id}/containers/${containerId}`, {
method: "DELETE",
});
}
@@ -37,6 +37,14 @@ export interface HoneyBatch {
measurements: HoneyMeasurement[];
}
export interface HoneyBatchContainerAllocation {
id: string;
honeyBatchId: string;
storageContainerId: string;
storageContainer: StorageContainerRef;
amount: number;
}
export interface HoneyBatchRelocation {
id: string;
fromStorageContainerId: string | null;