feat: allow a honey batch to be split across multiple storage containers
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:
1 parent
c91dc9ebf1
commit
1054a845ef
10 files changed
+579
-7
No files matched your search
+190
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user