feat(frontend): Varroamessungen-Modul (Modul 7)
Mobile-optimiertes Erfassungsformular unter /varroa/new sowie "Varroa"-Ansicht mit Verlaufsgrafik auf der Volk-Detailseite.
This commit is contained in:
1 parent
f363bffed1
commit
66c14cf306
6 files changed
+323
-1
No files matched your search
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const CHART_WIDTH = 280;
|
||||||
|
const CHART_HEIGHT = 80;
|
||||||
|
const CHART_PADDING = 8;
|
||||||
|
|
||||||
|
function VarroaTrendChart({ values }: { values: number[] }) {
|
||||||
|
if (values.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const usableWidth = CHART_WIDTH - CHART_PADDING * 2;
|
||||||
|
const usableHeight = CHART_HEIGHT - CHART_PADDING * 2;
|
||||||
|
const points = values
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = CHART_PADDING + (index / (values.length - 1)) * usableWidth;
|
||||||
|
const y = CHART_PADDING + usableHeight - ((value - min) / range) * usableHeight;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||||
|
className="w-full text-primary"
|
||||||
|
role="img"
|
||||||
|
aria-label="Verlauf der Varroamessungen"
|
||||||
|
>
|
||||||
|
<polyline points={points} fill="none" stroke="currentColor" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
||||||
|
const { data: measurements, isLoading } = useQuery({
|
||||||
|
queryKey: ["colonies", colonyId, "varroa-measurements"],
|
||||||
|
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = (measurements ?? [])
|
||||||
|
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Varroa</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href={`/varroa/new?colonyId=${colonyId}`} />}
|
||||||
|
>
|
||||||
|
Neue Messung
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 text-sm">
|
||||||
|
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{!isLoading && (!measurements || measurements.length === 0) ? (
|
||||||
|
<p className="text-muted-foreground">Noch keine Varroamessung dokumentiert.</p>
|
||||||
|
) : null}
|
||||||
|
<VarroaTrendChart values={values} />
|
||||||
|
{measurements?.map((measurement) => (
|
||||||
|
<div key={measurement.id} className="rounded-md border p-3">
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(measurement.date).toLocaleDateString("de-DE")} · {measurement.method}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
||||||
|
{measurement.calculatedValue !== null
|
||||||
|
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ColonyEditCard } from "./colony-edit-card";
|
|||||||
import { ColonyActions } from "./colony-actions";
|
import { ColonyActions } from "./colony-actions";
|
||||||
import { ColonyHistoryCard } from "./colony-history";
|
import { ColonyHistoryCard } from "./colony-history";
|
||||||
import { ColonyInspectionsCard } from "./colony-inspections";
|
import { ColonyInspectionsCard } from "./colony-inspections";
|
||||||
|
import { ColonyVarroaCard } from "./colony-varroa";
|
||||||
|
|
||||||
export default function ColonyDetailPage() {
|
export default function ColonyDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -31,6 +32,7 @@ export default function ColonyDetailPage() {
|
|||||||
<ColonyEditCard key={colony.id} colony={colony} />
|
<ColonyEditCard key={colony.id} colony={colony} />
|
||||||
<ColonyActions colony={colony} />
|
<ColonyActions colony={colony} />
|
||||||
<ColonyInspectionsCard colonyId={colony.id} />
|
<ColonyInspectionsCard colonyId={colony.id} />
|
||||||
|
<ColonyVarroaCard colonyId={colony.id} />
|
||||||
<ColonyHistoryCard colonyId={colony.id} />
|
<ColonyHistoryCard colonyId={colony.id} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"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 { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||||
|
import { VarroaMethod } from "@/lib/varroa/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 METHOD_OPTIONS: VarroaMethod[] = [
|
||||||
|
"NATURAL_MITE_FALL",
|
||||||
|
"POWDERED_SUGAR",
|
||||||
|
"WASHING_METHOD",
|
||||||
|
"DRONE_BROOD_CONTROL",
|
||||||
|
];
|
||||||
|
|
||||||
|
function todayIsoDate(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewVarroaMeasurementPage() {
|
||||||
|
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 [method, setMethod] = useState<VarroaMethod>("NATURAL_MITE_FALL");
|
||||||
|
const [miteCount, setMiteCount] = useState("");
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState("");
|
||||||
|
const [assessment, setAssessment] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createVarroaMeasurement,
|
||||||
|
onSuccess: (measurement) => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
||||||
|
});
|
||||||
|
router.push(`/colonies/${measurement.colonyId}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError("Varroamessung konnte nicht gespeichert werden.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!colonyId) {
|
||||||
|
setError("Bitte ein Volk auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutation.mutate({
|
||||||
|
colonyId,
|
||||||
|
date: new Date(date).toISOString(),
|
||||||
|
method,
|
||||||
|
miteCount: miteCount ? Number(miteCount) : undefined,
|
||||||
|
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
|
||||||
|
assessment: assessment || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 sm:p-6">
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neue Varroamessung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="varroa-colony">Volk</Label>
|
||||||
|
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||||
|
<SelectTrigger id="varroa-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="varroa-date">Datum</Label>
|
||||||
|
<Input
|
||||||
|
id="varroa-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="varroa-method">Methode</Label>
|
||||||
|
<Select
|
||||||
|
value={method}
|
||||||
|
onValueChange={(value) => setMethod((value ?? "NATURAL_MITE_FALL") as VarroaMethod)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="varroa-method" className="h-11 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{METHOD_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="varroa-mite-count">Milbenzahl</Label>
|
||||||
|
<Input
|
||||||
|
id="varroa-mite-count"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-11"
|
||||||
|
value={miteCount}
|
||||||
|
onChange={(event) => setMiteCount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="varroa-duration">Zeitraum (Minuten)</Label>
|
||||||
|
<Input
|
||||||
|
id="varroa-duration"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-11"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="varroa-assessment">Einschätzung</Label>
|
||||||
|
<Input
|
||||||
|
id="varroa-assessment"
|
||||||
|
className="h-11"
|
||||||
|
value={assessment}
|
||||||
|
onChange={(event) => setAssessment(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="varroa-notes">Notizen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="varroa-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…" : "Messung speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateVarroaMeasurementInput, VarroaMeasurement } from "./types";
|
||||||
|
|
||||||
|
export function fetchColonyVarroaMeasurements(colonyId: string): Promise<VarroaMeasurement[]> {
|
||||||
|
return apiFetch<VarroaMeasurement[]>(`/colonies/${colonyId}/varroa-measurements`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVarroaMeasurement(
|
||||||
|
input: CreateVarroaMeasurementInput,
|
||||||
|
): Promise<VarroaMeasurement> {
|
||||||
|
return apiFetch<VarroaMeasurement>("/varroa-measurements", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type VarroaMethod =
|
||||||
|
| "NATURAL_MITE_FALL"
|
||||||
|
| "POWDERED_SUGAR"
|
||||||
|
| "WASHING_METHOD"
|
||||||
|
| "DRONE_BROOD_CONTROL";
|
||||||
|
|
||||||
|
export interface VarroaMeasurement {
|
||||||
|
id: string;
|
||||||
|
colonyId: string;
|
||||||
|
userId: string;
|
||||||
|
date: string;
|
||||||
|
method: VarroaMethod;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
miteCount: number | null;
|
||||||
|
calculatedValue: number | null;
|
||||||
|
assessment: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVarroaMeasurementInput {
|
||||||
|
colonyId: string;
|
||||||
|
date: string;
|
||||||
|
method: VarroaMethod;
|
||||||
|
durationMinutes?: number;
|
||||||
|
miteCount?: number;
|
||||||
|
calculatedValue?: number;
|
||||||
|
assessment?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
|||||||
- Endpunkte: `GET/POST /api/v1/varroa-measurements`, `GET /api/v1/colonies/:id/varroa-measurements`
|
- Endpunkte: `GET/POST /api/v1/varroa-measurements`, `GET /api/v1/colonies/:id/varroa-measurements`
|
||||||
- Frontend: `/varroa/new`, `/colonies/[id]` Tab "Varroa" mit Verlaufsgrafik
|
- Frontend: `/varroa/new`, `/colonies/[id]` Tab "Varroa" mit Verlaufsgrafik
|
||||||
- [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
|
||||||
|
|
||||||
### 8. Behandlungen
|
### 8. Behandlungen
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user