feat(frontend): Honigchargen-Ansichten (Modul 11)

Chargenliste mit Anlage-Dialog sowie Detailseite mit
Honigmessungen und Rückverfolgbarkeits-Ansicht bis zu den
Ursprungsvölkern.
This commit is contained in:
Nick Szaunig committed 2026-08-22 04:58:47 +02:00
1 parent cf625552d1
commit f3d99524ff
8 files changed
+525 -1

No files matched your search

@@ -0,0 +1,110 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [waterContent, setWaterContent] = useState("");
const [measuredAt, setMeasuredAt] = useState(todayIsoDate());
const [device, setDevice] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (input: { waterContent: number; measuredAt: string; device?: string }) =>
addHoneyMeasurement(honeyBatchId, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatchId] });
setOpen(false);
setWaterContent("");
setDevice("");
setError(null);
},
onError: () => {
setError("Messung konnte nicht gespeichert werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!waterContent) {
setError("Bitte einen Wassergehalt angeben.");
return;
}
mutation.mutate({
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Honigmessung</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-water">Wassergehalt (%)</Label>
<Input
id="measurement-water"
type="number"
inputMode="decimal"
required
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-date">Datum</Label>
<Input
id="measurement-date"
type="date"
required
value={measuredAt}
onChange={(event) => setMeasuredAt(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-device">Messgerät</Label>
<Input
id="measurement-device"
value={device}
onChange={(event) => setDevice(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>();
const { data: honeyBatch, isLoading, isError } = useQuery({
queryKey: ["honey-batches", params.id],
queryFn: () => fetchHoneyBatch(params.id),
});
const { data: traceability } = useQuery({
queryKey: ["honey-batches", params.id, "traceability"],
queryFn: () => fetchHoneyBatchTraceability(params.id),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !honeyBatch) {
return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
Honigcharge konnte nicht geladen werden.
</p>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{honeyBatch.batchNumber}</CardTitle>
<Badge>{honeyBatch.status}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Honigmessungen</CardTitle>
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{honeyBatch.measurements.length === 0 ? (
<p className="text-muted-foreground">Noch keine Messung dokumentiert.</p>
) : null}
{honeyBatch.measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? <p className="text-muted-foreground">{measurement.device}</p> : null}
</div>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Rückverfolgbarkeit</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
{traceability ? (
<>
<p>
Schleuderung am{" "}
{new Date(traceability.extraction.date).toLocaleDateString("de-DE")}
{traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""}
</p>
<div className="flex flex-col gap-2">
{traceability.harvests.map((harvest) => (
<div key={harvest.id} className="rounded-md border p-3">
<p className="font-medium">
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}
</p>
<p className="text-muted-foreground">
Völker: {harvest.colonies.map((colony) => colony.code).join(", ")}
</p>
</div>
))}
</div>
</>
) : null}
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,138 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchExtractions } from "@/lib/extractions/api";
import { createHoneyBatch } from "@/lib/honey-batches/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function CreateHoneyBatchDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [batchNumber, setBatchNumber] = useState("");
const [extractionId, setExtractionId] = useState("");
const [honeyType, setHoneyType] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: fetchExtractions,
});
const mutation = useMutation({
mutationFn: createHoneyBatch,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
setOpen(false);
setBatchNumber("");
setExtractionId("");
setHoneyType("");
setAmount("");
setError(null);
},
onError: () => {
setError("Honigcharge konnte nicht angelegt werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!extractionId) {
setError("Bitte eine Schleuderung auswählen.");
return;
}
mutation.mutate({
batchNumber,
extractionId,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button>+ Neue Charge</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Honigcharge</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-number">Chargennummer</Label>
<Input
id="batch-number"
required
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-extraction">Schleuderung</Label>
<Select value={extractionId} onValueChange={(value) => setExtractionId(value ?? "")}>
<SelectTrigger id="batch-extraction">
<SelectValue placeholder="Schleuderung wählen" />
</SelectTrigger>
<SelectContent>
{extractions?.map((extraction) => (
<SelectItem key={extraction.id} value={extraction.id}>
{new Date(extraction.date).toLocaleDateString("de-DE")} ·{" "}
{extraction.harvests
.flatMap((entry) => entry.harvest.colonies.map((c) => c.colony.code))
.join(", ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-honey-type">Honigsorte</Label>
<Input
id="batch-honey-type"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-amount">Menge (kg)</Label>
<Input
id="batch-amount"
type="number"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
export default function HoneyBatchesPage() {
const { data: honeyBatches, isLoading, isError } = useQuery({
queryKey: ["honey-batches"],
queryFn: fetchHoneyBatches,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Honigchargen</h1>
<CreateHoneyBatchDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
Honigchargen konnten nicht geladen werden.
</p>
) : null}
{honeyBatches ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Chargennummer</TableHead>
<TableHead>Sorte</TableHead>
<TableHead>Menge</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{honeyBatches.map((honeyBatch) => (
<TableRow key={honeyBatch.id}>
<TableCell>
<Link className="underline" href={`/honey-batches/${honeyBatch.id}`}>
{honeyBatch.batchNumber}
</Link>
</TableCell>
<TableCell>{honeyBatch.honeyType ?? "—"}</TableCell>
<TableCell>
{honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—"}
</TableCell>
<TableCell>
<Badge>{honeyBatch.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</main>
);
}
@@ -37,6 +37,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/tasks">
Aufgaben
</Link>
<Link
className="text-sm text-muted-foreground hover:text-foreground"
href="/honey-batches"
>
Honigchargen
</Link>
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
@@ -0,0 +1,36 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
HoneyBatch,
Traceability,
} from "./types";
export function fetchHoneyBatches(): Promise<HoneyBatch[]> {
return apiFetch<HoneyBatch[]>("/honey-batches");
}
export function fetchHoneyBatch(id: string): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}`);
}
export function fetchHoneyBatchTraceability(id: string): Promise<Traceability> {
return apiFetch<Traceability>(`/honey-batches/${id}/traceability`);
}
export function createHoneyBatch(input: CreateHoneyBatchInput): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>("/honey-batches", {
method: "POST",
body: JSON.stringify(input),
});
}
export function addHoneyMeasurement(
id: string,
input: CreateHoneyMeasurementInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}/measurements`, {
method: "POST",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,68 @@
export type HoneyBatchStatus =
| "RAW"
| "EXTRACTED"
| "CLARIFIED"
| "CREAMED"
| "STORED"
| "BOTTLED"
| "SOLD"
| "CONSUMED";
export interface HoneyMeasurement {
id: string;
waterContent: number;
measuredAt: string;
device: string | null;
temperature: number | null;
notes: string | null;
}
export interface HoneyBatch {
id: string;
batchNumber: string;
extractionId: string;
honeyType: string | null;
amount: number | null;
waterContent: number | null;
storageContainerId: string | null;
status: HoneyBatchStatus;
createdAt: string;
measurements: HoneyMeasurement[];
}
export interface Traceability {
honeyBatch: {
id: string;
batchNumber: string;
honeyType: string | null;
amount: number | null;
status: HoneyBatchStatus;
};
extraction: {
id: string;
date: string;
room: string | null;
};
harvests: {
id: string;
date: string;
apiaryId: string;
colonies: { id: string; code: string }[];
}[];
}
export interface CreateHoneyBatchInput {
batchNumber: string;
extractionId: string;
honeyType?: string;
amount?: number;
waterContent?: number;
}
export interface CreateHoneyMeasurementInput {
waterContent: number;
measuredAt: string;
device?: string;
temperature?: number;
notes?: string;
}