feat(frontend): Fütterungen-Modul (Modul 9)
Mobile-optimiertes Erfassungsformular unter /feedings/new sowie "Fütterung"-Ansicht mit Mengensumme auf der Volk-Detailseite.
This commit is contained in:
1 parent
cc0aaf0a31
commit
d71785d531
6 files changed
+307
-1
No files matched your search
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchColonyFeedings, fetchColonyFeedingsSummary } from "@/lib/feedings/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
|
||||
const { data: feedings, isLoading } = useQuery({
|
||||
queryKey: ["colonies", colonyId, "feedings"],
|
||||
queryFn: () => fetchColonyFeedings(colonyId),
|
||||
});
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ["colonies", colonyId, "feedings", "summary"],
|
||||
queryFn: () => fetchColonyFeedingsSummary(colonyId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Fütterung</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/feedings/new?colonyId=${colonyId}`} />}
|
||||
>
|
||||
Neue Fütterung
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{summary && summary.length > 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Gesamt:{" "}
|
||||
{summary
|
||||
.map((entry) => `${entry.totalAmount.toFixed(1)} ${entry.unit}`)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{!isLoading && (!feedings || feedings.length === 0) ? (
|
||||
<p className="text-muted-foreground">Noch keine Fütterung dokumentiert.</p>
|
||||
) : null}
|
||||
{feedings?.map((feeding) => (
|
||||
<div key={feeding.id} className="rounded-md border p-3">
|
||||
<p className="font-medium">
|
||||
{new Date(feeding.date).toLocaleDateString("de-DE")} · {feeding.feedType}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{feeding.amount} {feeding.unit}
|
||||
{feeding.product ? ` · ${feeding.product}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ColonyHistoryCard } from "./colony-history";
|
||||
import { ColonyInspectionsCard } from "./colony-inspections";
|
||||
import { ColonyVarroaCard } from "./colony-varroa";
|
||||
import { ColonyTreatmentsCard } from "./colony-treatments";
|
||||
import { ColonyFeedingsCard } from "./colony-feedings";
|
||||
|
||||
export default function ColonyDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -35,6 +36,7 @@ export default function ColonyDetailPage() {
|
||||
<ColonyInspectionsCard colonyId={colony.id} />
|
||||
<ColonyVarroaCard colonyId={colony.id} />
|
||||
<ColonyTreatmentsCard colonyId={colony.id} />
|
||||
<ColonyFeedingsCard colonyId={colony.id} />
|
||||
<ColonyHistoryCard colonyId={colony.id} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { FeedType } from "@/lib/feedings/types";
|
||||
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";
|
||||
|
||||
const FEED_TYPE_OPTIONS: FeedType[] = [
|
||||
"SUGAR_WATER",
|
||||
"FEED_SYRUP",
|
||||
"FEED_PASTE",
|
||||
"OWN_HONEY",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function NewFeedingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: colonies } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: fetchColonies,
|
||||
});
|
||||
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
|
||||
|
||||
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
|
||||
const [date, setDate] = useState(todayIsoDate());
|
||||
const [feedType, setFeedType] = useState<FeedType>("SUGAR_WATER");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [unit, setUnit] = useState("l");
|
||||
const [product, setProduct] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createFeeding,
|
||||
onSuccess: (feeding) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", feeding.colonyId, "feedings", "summary"],
|
||||
});
|
||||
router.push(`/colonies/${feeding.colonyId}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Fütterung konnte nicht gespeichert werden.");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!colonyId) {
|
||||
setError("Bitte ein Volk auswählen.");
|
||||
return;
|
||||
}
|
||||
if (!amount) {
|
||||
setError("Bitte eine Menge angeben.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
feedType,
|
||||
amount: Number(amount),
|
||||
unit,
|
||||
product: product || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Fütterung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-colony">Volk</Label>
|
||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||
<SelectTrigger id="feeding-colony" className="h-11 w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeColonies.map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-date">Datum</Label>
|
||||
<Input
|
||||
id="feeding-date"
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-type">Futterart</Label>
|
||||
<Select
|
||||
value={feedType}
|
||||
onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)}
|
||||
>
|
||||
<SelectTrigger id="feeding-type" className="h-11 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FEED_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-amount">Menge</Label>
|
||||
<Input
|
||||
id="feeding-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
required
|
||||
className="h-11"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="feeding-unit">Einheit</Label>
|
||||
<Input
|
||||
id="feeding-unit"
|
||||
required
|
||||
className="h-11"
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-product">Produkt</Label>
|
||||
<Input
|
||||
id="feeding-product"
|
||||
className="h-11"
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="feeding-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…" : "Fütterung speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateFeedingInput, Feeding, FeedingSummaryEntry } from "./types";
|
||||
|
||||
export function fetchColonyFeedings(colonyId: string): Promise<Feeding[]> {
|
||||
return apiFetch<Feeding[]>(`/colonies/${colonyId}/feedings`);
|
||||
}
|
||||
|
||||
export function fetchColonyFeedingsSummary(colonyId: string): Promise<FeedingSummaryEntry[]> {
|
||||
return apiFetch<FeedingSummaryEntry[]>(`/colonies/${colonyId}/feedings/summary`);
|
||||
}
|
||||
|
||||
export function createFeeding(input: CreateFeedingInput): Promise<Feeding> {
|
||||
return apiFetch<Feeding>("/feedings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type FeedType = "SUGAR_WATER" | "FEED_SYRUP" | "FEED_PASTE" | "OWN_HONEY" | "OTHER";
|
||||
|
||||
export interface Feeding {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
feedType: FeedType;
|
||||
product: string | null;
|
||||
amount: number;
|
||||
unit: string;
|
||||
batchNumber: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedingSummaryEntry {
|
||||
unit: string;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface CreateFeedingInput {
|
||||
colonyId: string;
|
||||
date: string;
|
||||
feedType: FeedType;
|
||||
product?: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
batchNumber?: string;
|
||||
notes?: string;
|
||||
}
|
||||
Reference in new issue
Block a user