feat(frontend): Durchsichten-Modul (Modul 5)
Mobile-optimiertes Schnellformular unter /inspections/new sowie "Durchsichten"-Ansicht auf der Volk-Detailseite.
This commit is contained in:
1 parent
9b59e4bb35
commit
829753ec90
6 files changed
+347
-1
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchColonyInspections } from "@/lib/inspections/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
|
||||||
|
const { data: inspections, isLoading } = useQuery({
|
||||||
|
queryKey: ["colonies", colonyId, "inspections"],
|
||||||
|
queryFn: () => fetchColonyInspections(colonyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Durchsichten</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href={`/inspections/new?colonyId=${colonyId}`} />}
|
||||||
|
>
|
||||||
|
Neue Durchsicht
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 text-sm">
|
||||||
|
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{!isLoading && (!inspections || inspections.length === 0) ? (
|
||||||
|
<p className="text-muted-foreground">Noch keine Durchsicht dokumentiert.</p>
|
||||||
|
) : null}
|
||||||
|
{inspections?.map((inspection) => (
|
||||||
|
<div key={inspection.id} className="rounded-md border p-3">
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(inspection.date).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{inspection.colonyStrength !== null
|
||||||
|
? `${inspection.colonyStrength} Waben`
|
||||||
|
: "Stärke unbekannt"}
|
||||||
|
{inspection.queenSeen !== null
|
||||||
|
? ` · Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{inspection.healthIssues ? <p>Gesundheit: {inspection.healthIssues}</p> : null}
|
||||||
|
{inspection.varroaIssues ? <p>Varroa: {inspection.varroaIssues}</p> : null}
|
||||||
|
{inspection.measures ? <p>Maßnahmen: {inspection.measures}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { fetchColony } from "@/lib/colonies/api";
|
|||||||
import { ColonyEditCard } from "./colony-edit-card";
|
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";
|
||||||
|
|
||||||
export default function ColonyDetailPage() {
|
export default function ColonyDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -29,6 +30,7 @@ export default function ColonyDetailPage() {
|
|||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<ColonyEditCard key={colony.id} colony={colony} />
|
<ColonyEditCard key={colony.id} colony={colony} />
|
||||||
<ColonyActions colony={colony} />
|
<ColonyActions colony={colony} />
|
||||||
|
<ColonyInspectionsCard colonyId={colony.id} />
|
||||||
<ColonyHistoryCard colonyId={colony.id} />
|
<ColonyHistoryCard colonyId={colony.id} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"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 { createInspection } from "@/lib/inspections/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 NewInspectionPage() {
|
||||||
|
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 [colonyStrength, setColonyStrength] = useState("");
|
||||||
|
const [queenSeen, setQueenSeen] = useState(false);
|
||||||
|
const [queenCellsPresent, setQueenCellsPresent] = useState(false);
|
||||||
|
const [feedStatusOk, setFeedStatusOk] = useState(true);
|
||||||
|
const [healthIssues, setHealthIssues] = useState("");
|
||||||
|
const [varroaIssues, setVarroaIssues] = useState("");
|
||||||
|
const [measures, setMeasures] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createInspection,
|
||||||
|
onSuccess: (inspection) => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
||||||
|
});
|
||||||
|
router.push(`/colonies/${inspection.colonyId}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError("Durchsicht 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(),
|
||||||
|
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
|
||||||
|
queenSeen,
|
||||||
|
queenCellsPresent,
|
||||||
|
feedStatus: feedStatusOk ? "ausreichend" : "niedrig",
|
||||||
|
healthIssues: healthIssues || undefined,
|
||||||
|
varroaIssues: varroaIssues || undefined,
|
||||||
|
measures: measures || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 sm:p-6">
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neue Durchsicht</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="inspection-colony">Volk</Label>
|
||||||
|
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||||
|
<SelectTrigger id="inspection-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="inspection-date">Datum</Label>
|
||||||
|
<Input
|
||||||
|
id="inspection-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="inspection-strength">Volksstärke (Waben)</Label>
|
||||||
|
<Input
|
||||||
|
id="inspection-strength"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-11"
|
||||||
|
value={colonyStrength}
|
||||||
|
onChange={(event) => setColonyStrength(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="flex h-11 items-center gap-3 text-sm" htmlFor="inspection-queen-seen">
|
||||||
|
<input
|
||||||
|
id="inspection-queen-seen"
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5"
|
||||||
|
checked={queenSeen}
|
||||||
|
onChange={(event) => setQueenSeen(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Königin gesehen
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="flex h-11 items-center gap-3 text-sm"
|
||||||
|
htmlFor="inspection-queen-cells"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="inspection-queen-cells"
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5"
|
||||||
|
checked={queenCellsPresent}
|
||||||
|
onChange={(event) => setQueenCellsPresent(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Weiselzellen vorhanden
|
||||||
|
</label>
|
||||||
|
<label className="flex h-11 items-center gap-3 text-sm" htmlFor="inspection-feed-ok">
|
||||||
|
<input
|
||||||
|
id="inspection-feed-ok"
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5"
|
||||||
|
checked={feedStatusOk}
|
||||||
|
onChange={(event) => setFeedStatusOk(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Futtervorrat ausreichend
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="inspection-varroa">Varroa-Befund</Label>
|
||||||
|
<Input
|
||||||
|
id="inspection-varroa"
|
||||||
|
className="h-11"
|
||||||
|
value={varroaIssues}
|
||||||
|
onChange={(event) => setVarroaIssues(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="inspection-health">Gesundheitsauffälligkeiten</Label>
|
||||||
|
<Input
|
||||||
|
id="inspection-health"
|
||||||
|
className="h-11"
|
||||||
|
value={healthIssues}
|
||||||
|
onChange={(event) => setHealthIssues(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="inspection-measures">Maßnahmen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="inspection-measures"
|
||||||
|
value={measures}
|
||||||
|
onChange={(event) => setMeasures(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="inspection-notes">Notizen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="inspection-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…" : "Durchsicht speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import {
|
||||||
|
CreateInspectionInput,
|
||||||
|
CreateTaskFromInspectionInput,
|
||||||
|
Inspection,
|
||||||
|
InspectionTask,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function fetchColonyInspections(colonyId: string): Promise<Inspection[]> {
|
||||||
|
return apiFetch<Inspection[]>(`/colonies/${colonyId}/inspections`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInspection(input: CreateInspectionInput): Promise<Inspection> {
|
||||||
|
return apiFetch<Inspection>("/inspections", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskFromInspection(
|
||||||
|
id: string,
|
||||||
|
input: CreateTaskFromInspectionInput,
|
||||||
|
): Promise<InspectionTask> {
|
||||||
|
return apiFetch<InspectionTask>(`/inspections/${id}/create-task`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export interface Inspection {
|
||||||
|
id: string;
|
||||||
|
colonyId: string;
|
||||||
|
userId: string;
|
||||||
|
date: string;
|
||||||
|
weather: string | null;
|
||||||
|
temperature: number | null;
|
||||||
|
colonyStrength: number | null;
|
||||||
|
broodAmount: string | null;
|
||||||
|
broodPattern: number | null;
|
||||||
|
openBroodPresent: boolean | null;
|
||||||
|
cappedBroodPresent: boolean | null;
|
||||||
|
eggsPresent: boolean | null;
|
||||||
|
queenSeen: boolean | null;
|
||||||
|
queenCellsPresent: boolean | null;
|
||||||
|
swarmMood: number | null;
|
||||||
|
gentleness: number | null;
|
||||||
|
combBehavior: number | null;
|
||||||
|
feedStatus: string | null;
|
||||||
|
honeySuperStatus: string | null;
|
||||||
|
healthIssues: string | null;
|
||||||
|
varroaIssues: string | null;
|
||||||
|
measures: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInspectionInput {
|
||||||
|
colonyId: string;
|
||||||
|
date: string;
|
||||||
|
weather?: string;
|
||||||
|
temperature?: number;
|
||||||
|
colonyStrength?: number;
|
||||||
|
queenSeen?: boolean;
|
||||||
|
queenCellsPresent?: boolean;
|
||||||
|
feedStatus?: string;
|
||||||
|
healthIssues?: string;
|
||||||
|
varroaIssues?: string;
|
||||||
|
measures?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskFromInspectionInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
assignedUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionTask {
|
||||||
|
id: string;
|
||||||
|
sourceInspectionId: string | null;
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
|||||||
- Endpunkte: `GET/POST /api/v1/inspections`, `GET /api/v1/inspections/:id`, `GET /api/v1/colonies/:id/inspections`, `POST /api/v1/inspections/:id/create-task`
|
- Endpunkte: `GET/POST /api/v1/inspections`, `GET /api/v1/inspections/:id`, `GET /api/v1/colonies/:id/inspections`, `POST /api/v1/inspections/:id/create-task`
|
||||||
- Frontend: `/inspections/new` (mobile-optimiertes Schnellformular), `/colonies/[id]` Tab "Durchsichten"
|
- Frontend: `/inspections/new` (mobile-optimiertes Schnellformular), `/colonies/[id]` Tab "Durchsichten"
|
||||||
- [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
|
||||||
|
|
||||||
### 6. Aufgaben
|
### 6. Aufgaben
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user