fix: add missing Ernten/Schleuderungen list and detail views

The sidebar nav sent both entries straight to their "new" creation
form, with no list page to ever browse what had been created -
Schleuderung creation didn't even redirect to a detail page, just to
/colonies, so a saved extraction left no trace anywhere in the UI.

- New /harvests and /extractions list pages (mirrors the existing
  products/apiaries list pattern).
- New /extractions/[id] detail page + edit dialog, matching the
  harvests one that already existed but had nothing linking to it.
- Sidebar "Ernten"/"Schleuderungen" now point at the list pages instead
  of the creation forms.
- Extraction creation now redirects to its detail page instead of
  /colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:44:19 +02:00
1 parent e133a677ab
commit c91dc9ebf1
6 files changed
+375 -4

No files matched your search

@@ -0,0 +1,129 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateExtraction } from "@/lib/extractions/api";
import { Extraction } from "@/lib/extractions/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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
const [room, setRoom] = useState(extraction.room ?? "");
const [waterContent, setWaterContent] = useState(extraction.waterContent?.toString() ?? "");
const [temperature, setTemperature] = useState(extraction.temperature?.toString() ?? "");
const [notes, setNotes] = useState(extraction.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
id="extraction-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-amount">Menge (kg)</Label>
<Input
id="extraction-edit-amount"
type="number"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-room">Schleuderraum</Label>
<Input
id="extraction-edit-room"
value={room}
onChange={(event) => setRoom(event.target.value)}
/>
</div>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-water">Wassergehalt (%)</Label>
<Input
id="extraction-edit-water"
type="number"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-temperature">Temperatur (°C)</Label>
<Input
id="extraction-edit-temperature"
type="number"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-notes">Notizen</Label>
<Textarea
id="extraction-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FlaskConical } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchExtraction } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
export default function ExtractionDetailPage() {
const params = useParams<{ id: string }>();
const {
data: extraction,
isLoading,
isError,
} = useQuery({
queryKey: ["extractions", params.id],
queryFn: () => fetchExtraction(params.id),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !extraction) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Schleuderung konnte nicht geladen werden.
</p>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<FlaskConical className="size-4" />
</span>
<CardTitle>
Schleuderung vom {new Date(extraction.date).toLocaleDateString("de-DE")}
</CardTitle>
</div>
<ExtractionEditDialog extraction={extraction} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{extraction.amount !== null ? <p>Menge: {extraction.amount} kg</p> : null}
{extraction.room ? <p>Schleuderraum: {extraction.room}</p> : null}
{extraction.waterContent !== null ? (
<p>Wassergehalt: {extraction.waterContent} %</p>
) : null}
{extraction.temperature !== null ? (
<p>Temperatur: {extraction.temperature} °C</p>
) : null}
{extraction.notes ? <p>Notizen: {extraction.notes}</p> : null}
<div>
<p className="font-medium">Beteiligte Ernten</p>
<ul className="list-disc pl-5">
{extraction.harvests.map((entry) => (
<li key={entry.id}>
<Link href={`/harvests/${entry.harvestId}`} className="hover:underline">
{new Date(entry.harvest.date).toLocaleDateString("de-DE")} ·{" "}
{entry.harvest.colonies.map((c) => c.colony.code).join(", ")}
</Link>
</li>
))}
</ul>
</div>
<Button
className="w-fit"
variant="outline"
nativeButton={false}
render={<Link href="/honey-batches" />}
>
Zu den Honigchargen
</Button>
</CardContent>
</Card>
</main>
);
}
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/api";
@@ -37,9 +38,10 @@ export default function NewExtractionPage() {
const mutation = useMutation({
mutationFn: createExtraction,
onSuccess: () => {
onSuccess: (extraction) => {
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
router.push("/colonies");
toast.success("Schleuderung gespeichert");
router.push(`/extractions/${extraction.id}`);
},
onError: () => {
setError("Schleuderung konnte nicht gespeichert werden.");
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { FlaskConical } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchExtractions } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
export default function ExtractionsPage() {
const {
data: extractions,
isLoading,
isError,
} = useQuery({ queryKey: ["extractions"], queryFn: fetchExtractions });
const sortedExtractions = [...(extractions ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={FlaskConical}
title="Schleuderungen"
description="Erfasste Schleuderungen aus einer oder mehreren Ernten"
actions={
<Button nativeButton={false} render={<Link href="/extractions/new" />}>
+ Neue Schleuderung
</Button>
}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Schleuderungen konnten nicht geladen werden.
</p>
) : null}
{extractions && sortedExtractions.length === 0 ? (
<EmptyState
icon={FlaskConical}
title="Noch keine Schleuderungen"
description="Erfasse deine erste Schleuderung aus einer oder mehreren Ernten."
/>
) : null}
{sortedExtractions.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sortedExtractions.map((extraction) => (
<EntityCard
key={extraction.id}
href={`/extractions/${extraction.id}`}
icon={FlaskConical}
title={new Date(extraction.date).toLocaleDateString("de-DE")}
subtitle={extraction.room ?? "—"}
fields={[
{
label: "Menge",
value: extraction.amount !== null ? `${extraction.amount} kg` : "—",
},
{
label: "Wassergehalt",
value: extraction.waterContent !== null ? `${extraction.waterContent} %` : "—",
},
{ label: "Ernten", value: extraction.harvests.length },
]}
/>
))}
</div>
) : null}
</main>
);
}
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { Scissors } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchHarvests } from "@/lib/harvests/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
export default function HarvestsPage() {
const {
data: harvests,
isLoading,
isError,
} = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const apiaryNameById = new Map((apiaries ?? []).map((apiary) => [apiary.id, apiary.name]));
const sortedHarvests = [...(harvests ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Scissors}
title="Ernten"
description="Erfasste Ernten je Bienenstand"
actions={
<Button nativeButton={false} render={<Link href="/harvests/new" />}>
+ Neue Ernte
</Button>
}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Ernten konnten nicht geladen werden.
</p>
) : null}
{harvests && sortedHarvests.length === 0 ? (
<EmptyState
icon={Scissors}
title="Noch keine Ernten"
description="Erfasse deine erste Ernte für einen Bienenstand."
/>
) : null}
{sortedHarvests.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sortedHarvests.map((harvest) => (
<EntityCard
key={harvest.id}
href={`/harvests/${harvest.id}`}
icon={Scissors}
title={new Date(harvest.date).toLocaleDateString("de-DE")}
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
fields={[
{ label: "Honigsorte", value: harvest.honeyType ?? "—" },
{
label: "Nettogewicht",
value: harvest.netWeight !== null ? `${harvest.netWeight} kg` : "—",
},
{ label: "Völker", value: harvest.colonies.length },
]}
/>
))}
</div>
) : null}
</main>
);
}
+2 -2
View File
@@ -70,8 +70,8 @@ const NAV_GROUPS: NavGroup[] = [
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{ label: "Produkte", href: "/products", icon: ShoppingBag },