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

@@ -1,8 +1,10 @@
"use client"; "use client";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api"; import { fetchApiary } from "@/lib/apiaries/api";
import { Button } from "@/components/ui/button";
import { ApiaryEditCard } from "./apiary-edit-card"; import { ApiaryEditCard } from "./apiary-edit-card";
export default function ApiaryDetailPage() { export default function ApiaryDetailPage() {
@@ -24,8 +26,15 @@ export default function ApiaryDetailPage() {
} }
return ( return (
<main className="p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<ApiaryEditCard key={apiary.id} apiary={apiary} /> <ApiaryEditCard key={apiary.id} apiary={apiary} />
<Button
className="w-fit"
nativeButton={false}
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
>
Neue Ernte
</Button>
</main> </main>
); );
} }
@@ -0,0 +1,166 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewExtractionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: harvests } = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
const preselectedHarvestId = searchParams.get("harvestId");
const [date, setDate] = useState(todayIsoDate());
const [harvestIds, setHarvestIds] = useState<string[]>(
preselectedHarvestId ? [preselectedHarvestId] : [],
);
const [amount, setAmount] = useState("");
const [room, setRoom] = useState("");
const [waterContent, setWaterContent] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: createExtraction,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
router.push("/colonies");
},
onError: () => {
setError("Schleuderung konnte nicht gespeichert werden.");
},
});
function toggleHarvest(harvestId: string, checked: boolean) {
setHarvestIds((current) =>
checked ? [...current, harvestId] : current.filter((id) => id !== harvestId),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (harvestIds.length === 0) {
setError("Bitte mindestens eine Ernte auswählen.");
return;
}
mutation.mutate({
date: new Date(date).toISOString(),
harvestIds,
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
notes: notes || undefined,
});
}
return (
<main className="p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Neue Schleuderung</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-date">Datum</Label>
<Input
id="extraction-date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Ernten</Label>
{harvests?.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Ernten vorhanden.</p>
) : null}
<div className="flex flex-col gap-2">
{harvests?.map((harvest) => (
<label
key={harvest.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`extraction-harvest-${harvest.id}`}
>
<input
id={`extraction-harvest-${harvest.id}`}
type="checkbox"
className="size-5"
checked={harvestIds.includes(harvest.id)}
onChange={(event) => toggleHarvest(harvest.id, event.target.checked)}
/>
{new Date(harvest.date).toLocaleDateString("de-DE")} ·{" "}
{harvest.colonies.map((entry) => entry.colony.code).join(", ")}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-amount">Menge (kg)</Label>
<Input
id="extraction-amount"
type="number"
inputMode="decimal"
className="h-11"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-room">Schleuderraum</Label>
<Input
id="extraction-room"
className="h-11"
value={room}
onChange={(event) => setRoom(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-water">Wassergehalt (%)</Label>
<Input
id="extraction-water"
type="number"
inputMode="decimal"
className="h-11"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-notes">Notizen</Label>
<Textarea
id="extraction-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…" : "Schleuderung speichern"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -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>
);
}
@@ -0,0 +1,13 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateExtractionInput, Extraction } from "./types";
export function fetchExtractions(): Promise<Extraction[]> {
return apiFetch<Extraction[]>("/extractions");
}
export function createExtraction(input: CreateExtractionInput): Promise<Extraction> {
return apiFetch<Extraction>("/extractions", {
method: "POST",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,35 @@
export interface ExtractionHarvestEntry {
id: string;
harvestId: string;
harvest: {
id: string;
date: string;
colonies: {
colonyId: string;
colony: { id: string; code: string };
}[];
};
}
export interface Extraction {
id: string;
date: string;
userId: string;
amount: number | null;
room: string | null;
waterContent: number | null;
temperature: number | null;
notes: string | null;
createdAt: string;
harvests: ExtractionHarvestEntry[];
}
export interface CreateExtractionInput {
date: string;
harvestIds: string[];
amount?: number;
room?: string;
waterContent?: number;
temperature?: number;
notes?: string;
}
+17
View File
@@ -0,0 +1,17 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateHarvestInput, Harvest } from "./types";
export function fetchHarvests(): Promise<Harvest[]> {
return apiFetch<Harvest[]>("/harvests");
}
export function fetchHarvest(id: string): Promise<Harvest> {
return apiFetch<Harvest>(`/harvests/${id}`);
}
export function createHarvest(input: CreateHarvestInput): Promise<Harvest> {
return apiFetch<Harvest>("/harvests", {
method: "POST",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,33 @@
export interface HarvestColonyEntry {
id: string;
colonyId: string;
colony: {
id: string;
code: string;
};
}
export interface Harvest {
id: string;
apiaryId: string;
userId: string;
date: string;
honeyType: string | null;
honeySuperCount: number | null;
grossWeight: number | null;
netWeight: number | null;
notes: string | null;
createdAt: string;
colonies: HarvestColonyEntry[];
}
export interface CreateHarvestInput {
apiaryId: string;
date: string;
colonyIds: string[];
honeyType?: string;
honeySuperCount?: number;
grossWeight?: number;
netWeight?: number;
notes?: string;
}
+1 -1
View File
@@ -91,7 +91,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Endpunkte: `GET/POST /api/v1/harvests`, `GET /api/v1/harvests/:id`, `GET/POST /api/v1/extractions`, `GET /api/v1/extractions/:id` - Endpunkte: `GET/POST /api/v1/harvests`, `GET /api/v1/harvests/:id`, `GET/POST /api/v1/extractions`, `GET /api/v1/extractions/:id`
- Frontend: `/harvests/new`, `/harvests/[id]`, `/extractions/new` - Frontend: `/harvests/new`, `/harvests/[id]`, `/extractions/new`
- [x] Backend-Modul fertig, getestet, grün, committet - [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet - [x] Frontend-Views fertig, getestet, grün, committet
### 11. Honigchargen ### 11. Honigchargen