feat(frontend): Lagerbehälter-Sektion und Umlagerungshistorie (Modul 32)

Neue HoneyBatchStorageCard-Komponente auf der Honigcharge-Detailseite:
zeigt den aktuell zugeordneten Lagerbehälter, einen "Umlagern"-Dialog
(Ziel-Lagerbehälter wählen, optionale Notiz) und die vollständige
Umlagerungshistorie (von/nach, Zeitpunkt, Notiz) im etablierten
EmptyState-Stil.

HoneyBatch-Typ um storageContainer erweitert, neue
HoneyBatchRelocation/RelocateHoneyBatchInput-Typen und passende
API-Funktionen ergänzt. Live gegen das Backend verifiziert: Umlagerung
zwischen zwei echten Lagerbehältern aktualisiert die Anzeige und die
Historie korrekt, zero Konsolenfehler.
This commit is contained in:
Nick Szaunig committed 2026-08-24 17:06:54 +02:00
1 parent 59a320ae36
commit 15f5ab57ae
4 files changed
+199

No files matched your search

@@ -0,0 +1,159 @@
"use client";
import { FormEvent, useState } from "react";
import { History } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHoneyBatchRelocations, relocateHoneyBatch } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [storageContainerId, setStorageContainerId] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: containers } = useQuery({
queryKey: ["storage-containers"],
queryFn: fetchStorageContainers,
});
const { data: relocations, isLoading } = useQuery({
queryKey: ["honey-batches", honeyBatch.id, "relocations"],
queryFn: () => fetchHoneyBatchRelocations(honeyBatch.id),
});
const mutation = useMutation({
mutationFn: (input: { storageContainerId: string; notes?: string }) =>
relocateHoneyBatch(honeyBatch.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] });
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatch.id, "relocations"],
});
setOpen(false);
setStorageContainerId("");
setNotes("");
setError(null);
},
onError: () => {
setError("Umlagerung konnte nicht gespeichert werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!storageContainerId) {
setError("Bitte einen Lagerbehälter auswählen.");
return;
}
mutation.mutate({ storageContainerId, notes: notes || undefined });
}
return (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Lagerbehälter</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Umlagern</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Honigcharge umlagern</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="relocate-container">Ziel-Lagerbehälter</Label>
<Select
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="relocate-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="relocate-notes">Notizen</Label>
<Input
id="relocate-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird umgelagert…" : "Umlagern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
Aktuell:{" "}
{honeyBatch.storageContainer
? `${honeyBatch.storageContainer.code}${
honeyBatch.storageContainer.location
? ` · ${honeyBatch.storageContainer.location}`
: ""
}`
: "Kein Lagerbehälter zugewiesen"}
</p>
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!relocations || relocations.length === 0) ? (
<EmptyState
icon={History}
title="Noch keine Umlagerung"
description="Ordne die Charge einem Lagerbehälter zu, um die Historie zu starten."
/>
) : null}
{relocations?.map((relocation) => (
<div key={relocation.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(relocation.relocatedAt).toLocaleDateString("de-DE")} ·{" "}
{relocation.fromStorageContainer ? relocation.fromStorageContainer.code : "—"} →{" "}
{relocation.toStorageContainer.code}
</p>
{relocation.notes ? <p className="text-muted-foreground">{relocation.notes}</p> : null}
</div>
))}
</CardContent>
</Card>
);
}
@@ -9,6 +9,7 @@ import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -136,6 +137,7 @@ export default function HoneyBatchDetailPage() {
</CardContent>
</Card>
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
</main>
);
@@ -3,7 +3,9 @@ import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
HoneyBatch,
HoneyBatchRelocation,
HoneyBatchStatus,
RelocateHoneyBatchInput,
Traceability,
} from "./types";
@@ -42,3 +44,17 @@ export function addHoneyMeasurement(
body: JSON.stringify(input),
});
}
export function relocateHoneyBatch(
id: string,
input: RelocateHoneyBatchInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}/relocate`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
}
@@ -17,6 +17,12 @@ export interface HoneyMeasurement {
notes: string | null;
}
export interface StorageContainerRef {
id: string;
code: string;
location: string | null;
}
export interface HoneyBatch {
id: string;
batchNumber: string;
@@ -25,11 +31,27 @@ export interface HoneyBatch {
amount: number | null;
waterContent: number | null;
storageContainerId: string | null;
storageContainer: StorageContainerRef | null;
status: HoneyBatchStatus;
createdAt: string;
measurements: HoneyMeasurement[];
}
export interface HoneyBatchRelocation {
id: string;
fromStorageContainerId: string | null;
fromStorageContainer: StorageContainerRef | null;
toStorageContainerId: string;
toStorageContainer: StorageContainerRef;
notes: string | null;
relocatedAt: string;
}
export interface RelocateHoneyBatchInput {
storageContainerId: string;
notes?: string;
}
export interface Traceability {
honeyBatch: {
id: string;