feat(frontend): Bienenstaende-Views (Modul 2)
/apiaries: Liste mit Status-Badge; /apiaries/new: Anlegen-Formular;
/apiaries/[id]: Bearbeiten (Name/Standort/Adresse/Notizen) + Deaktivieren.
Editierbares Formular liest den Ausgangszustand direkt aus der geladenen
Apiary (kein useEffect+setState-Sync, React-Key-Reset-Pattern statt
Effect - vermeidet kaskadierende Renders).
(app)/layout.tsx: einfache Nav-Leiste (Bienenstände fuer alle, Benutzer
nur fuer Admins) - Platzhalter bis zur echten Bottom-Nav/Sidebar aus
Modul 15. proxy.ts-Matcher um /apiaries/* erweitert.
Live im Browser gefunden+behoben: Base-UI-Button meldete fehlende
native Button-Semantik beim Rendern als Link (`nativeButton={false}`
noetig, wenn Button auf ein <a> zeigt).
8 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow
(Anlegen, Liste, Bearbeiten, Deaktivieren, Navigation) live verifiziert.
This commit is contained in:
1 parent
5c92c810e1
commit
d59153a697
10 files changed
+427
-2
No files matched your search
@@ -22,5 +22,5 @@ export function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/login", "/settings/:path*"],
|
matcher: ["/login", "/settings/:path*", "/apiaries/:path*"],
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deactivateApiary, updateApiary } from "@/lib/apiaries/api";
|
||||||
|
import { Apiary } from "@/lib/apiaries/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState(apiary.name);
|
||||||
|
const [location, setLocation] = useState(apiary.location ?? "");
|
||||||
|
const [address, setAddress] = useState(apiary.address ?? "");
|
||||||
|
const [notes, setNotes] = useState(apiary.notes ?? "");
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateApiary(apiary.id, {
|
||||||
|
name,
|
||||||
|
location: location || undefined,
|
||||||
|
address: address || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateMutation = useMutation({
|
||||||
|
mutationFn: () => deactivateApiary(apiary.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
updateMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>{apiary.name}</CardTitle>
|
||||||
|
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
|
||||||
|
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-location">Standort</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-location"
|
||||||
|
value={location}
|
||||||
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-address">Adresse</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-address"
|
||||||
|
value={address}
|
||||||
|
onChange={(event) => setAddress(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-notes">Notizen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="apiary-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? "Speichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={apiary.status !== "ACTIVE" || deactivateMutation.isPending}
|
||||||
|
onClick={() => deactivateMutation.mutate()}
|
||||||
|
>
|
||||||
|
Deaktivieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchApiary } from "@/lib/apiaries/api";
|
||||||
|
import { ApiaryEditCard } from "./apiary-edit-card";
|
||||||
|
|
||||||
|
export default function ApiaryDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const { data: apiary, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["apiaries", params.id],
|
||||||
|
queryFn: () => fetchApiary(params.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||||
|
}
|
||||||
|
if (isError || !apiary) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
|
||||||
|
Bienenstand konnte nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 sm:p-6">
|
||||||
|
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createApiary } from "@/lib/apiaries/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";
|
||||||
|
|
||||||
|
export default function NewApiaryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createApiary,
|
||||||
|
onSuccess: (apiary) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||||
|
router.push(`/apiaries/${apiary.id}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError("Bienenstand konnte nicht angelegt werden.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate({
|
||||||
|
name,
|
||||||
|
location: location || undefined,
|
||||||
|
address: address || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 sm:p-6">
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neuer Bienenstand</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-location">Standort</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-location"
|
||||||
|
value={location}
|
||||||
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-address">Adresse</Label>
|
||||||
|
<Input
|
||||||
|
id="apiary-address"
|
||||||
|
value={address}
|
||||||
|
onChange={(event) => setAddress(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="apiary-notes">Notizen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="apiary-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" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import ApiariesPage from "./page";
|
||||||
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/apiaries/api", () => ({
|
||||||
|
fetchApiaries: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithClient(ui: ReactElement) {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ApiariesPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchApiaries).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the fetched apiaries", async () => {
|
||||||
|
vi.mocked(fetchApiaries).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Heimstand",
|
||||||
|
description: null,
|
||||||
|
location: "Garten",
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
address: null,
|
||||||
|
status: "ACTIVE",
|
||||||
|
notes: null,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
deactivatedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderWithClient(<ApiariesPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Heimstand")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Garten")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function ApiariesPage() {
|
||||||
|
const { data: apiaries, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["apiaries"],
|
||||||
|
queryFn: fetchApiaries,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Bienenstände</h1>
|
||||||
|
<Button nativeButton={false} render={<Link href="/apiaries/new" />}>
|
||||||
|
+ Neuer Bienenstand
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-red-600">
|
||||||
|
Bienenstände konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{apiaries ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Standort</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{apiaries.map((apiary) => (
|
||||||
|
<TableRow key={apiary.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link className="underline" href={`/apiaries/${apiary.id}`}>
|
||||||
|
{apiary.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{apiary.location ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
|
||||||
|
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode, useEffect } from "react";
|
import { ReactNode, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -22,7 +23,20 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
<span className="font-semibold">ApiCore</span>
|
<span className="font-semibold">ApiCore</span>
|
||||||
|
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/apiaries">
|
||||||
|
Bienenstände
|
||||||
|
</Link>
|
||||||
|
{user.role === "ADMIN" ? (
|
||||||
|
<Link
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
href="/settings/users"
|
||||||
|
>
|
||||||
|
Benutzer
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span className="text-muted-foreground">{user.email}</span>
|
<span className="text-muted-foreground">{user.email}</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { Apiary, ApiaryInput } from "./types";
|
||||||
|
|
||||||
|
export function fetchApiaries(): Promise<Apiary[]> {
|
||||||
|
return apiFetch<Apiary[]>("/apiaries");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApiary(id: string): Promise<Apiary> {
|
||||||
|
return apiFetch<Apiary>(`/apiaries/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiary(input: ApiaryInput): Promise<Apiary> {
|
||||||
|
return apiFetch<Apiary>("/apiaries", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateApiary(id: string, input: Partial<ApiaryInput>): Promise<Apiary> {
|
||||||
|
return apiFetch<Apiary>(`/apiaries/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateApiary(id: string): Promise<Apiary> {
|
||||||
|
return apiFetch<Apiary>(`/apiaries/${id}/deactivate`, { method: "POST" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type ApiaryStatus = "ACTIVE" | "INACTIVE";
|
||||||
|
|
||||||
|
export interface Apiary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
location: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
address: string | null;
|
||||||
|
status: ApiaryStatus;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deactivatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiaryInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
address?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
|||||||
- Endpunkte: `GET/POST /api/v1/apiaries`, `GET/PATCH /api/v1/apiaries/:id`, `POST /api/v1/apiaries/:id/deactivate`
|
- Endpunkte: `GET/POST /api/v1/apiaries`, `GET/PATCH /api/v1/apiaries/:id`, `POST /api/v1/apiaries/:id/deactivate`
|
||||||
- Frontend: `/apiaries`, `/apiaries/[id]`, `/apiaries/new`
|
- Frontend: `/apiaries`, `/apiaries/[id]`, `/apiaries/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
|
||||||
|
|
||||||
### 3. Völker
|
### 3. Völker
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user