feat(frontend): Ernten- und Schleuderungs-Ansichten (Modul 10)

/harvests/new (Bienenstand + Mehrfachauswahl Völker), /harvests/[id]
und /extractions/new (Mehrfachauswahl Ernten). Bienenstand- und
Ernte-Detailseiten verlinken direkt in die Kette.
This commit is contained in:
Nick Szaunig committed 2026-08-22 00:33:13 +02:00
1 parent 340397b34d
commit 5c2c0608d1
9 files changed
+526 -2

No files matched your search

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function HarvestDetailPage() {
const params = useParams<{ id: string }>();
const { data: harvest, isLoading, isError } = useQuery({
queryKey: ["harvests", params.id],
queryFn: () => fetchHarvest(params.id),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !harvest) {
return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
Ernte konnte nicht geladen werden.
</p>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Ernte vom {new Date(harvest.date).toLocaleDateString("de-DE")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{harvest.honeyType ? <p>Honigsorte: {harvest.honeyType}</p> : null}
{harvest.netWeight !== null ? <p>Nettogewicht: {harvest.netWeight} kg</p> : null}
{harvest.honeySuperCount !== null ? (
<p>Honigräume: {harvest.honeySuperCount}</p>
) : null}
{harvest.notes ? <p>Notizen: {harvest.notes}</p> : null}
<div>
<p className="font-medium">Beteiligte Völker</p>
<ul className="list-disc pl-5">
{harvest.colonies.map((entry) => (
<li key={entry.id}>{entry.colony.code}</li>
))}
</ul>
</div>
<Button
className="w-fit"
nativeButton={false}
render={<Link href={`/extractions/new?harvestId=${harvest.id}`} />}
>
Zur Schleuderung
</Button>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,191 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { createHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewHarvestPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: fetchApiaries });
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: fetchColonies });
const [apiaryId, setApiaryId] = useState(searchParams.get("apiaryId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [colonyIds, setColonyIds] = useState<string[]>([]);
const [honeyType, setHoneyType] = useState("");
const [netWeight, setNetWeight] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const apiaryColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
);
const mutation = useMutation({
mutationFn: createHarvest,
onSuccess: (harvest) => {
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
router.push(`/harvests/${harvest.id}`);
},
onError: () => {
setError("Ernte konnte nicht gespeichert werden.");
},
});
function toggleColony(colonyId: string, checked: boolean) {
setColonyIds((current) =>
checked ? [...current, colonyId] : current.filter((id) => id !== colonyId),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!apiaryId) {
setError("Bitte einen Bienenstand auswählen.");
return;
}
if (colonyIds.length === 0) {
setError("Bitte mindestens ein Volk auswählen.");
return;
}
mutation.mutate({
apiaryId,
date: new Date(date).toISOString(),
colonyIds,
honeyType: honeyType || undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
notes: notes || undefined,
});
}
return (
<main className="p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Neue Ernte</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-apiary">Bienenstand</Label>
<Select
value={apiaryId}
onValueChange={(value) => {
setApiaryId(value ?? "");
setColonyIds([]);
}}
>
<SelectTrigger id="harvest-apiary" className="h-11 w-full">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-date">Datum</Label>
<Input
id="harvest-date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
{apiaryId && apiaryColonies.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine aktiven Völker an diesem Bienenstand.
</p>
) : null}
<div className="flex flex-col gap-2">
{apiaryColonies.map((colony) => (
<label
key={colony.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-colony-${colony.id}`}
>
<input
id={`harvest-colony-${colony.id}`}
type="checkbox"
className="size-5"
checked={colonyIds.includes(colony.id)}
onChange={(event) => toggleColony(colony.id, event.target.checked)}
/>
{colony.code}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-honey-type">Honigsorte</Label>
<Input
id="harvest-honey-type"
className="h-11"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-net-weight">Nettogewicht (kg)</Label>
<Input
id="harvest-net-weight"
type="number"
inputMode="decimal"
className="h-11"
value={netWeight}
onChange={(event) => setNetWeight(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-notes">Notizen</Label>
<Textarea
id="harvest-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<Button type="submit" className="h-12 text-base" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}