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:
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 { FormEvent, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Droplet } from "lucide-react";
|
import { Droplet } from "lucide-react";
|
||||||
import { fetchHarvests } from "@/lib/harvests/api";
|
import { fetchHarvests } from "@/lib/harvests/api";
|
||||||
import { createExtraction } from "@/lib/extractions/api";
|
import { createExtraction } from "@/lib/extractions/api";
|
||||||
@@ -37,9 +38,10 @@ export default function NewExtractionPage() {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: createExtraction,
|
mutationFn: createExtraction,
|
||||||
onSuccess: () => {
|
onSuccess: (extraction) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
|
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
|
||||||
router.push("/colonies");
|
toast.success("Schleuderung gespeichert");
|
||||||
|
router.push(`/extractions/${extraction.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setError("Schleuderung konnte nicht gespeichert werden.");
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,8 +70,8 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: "Ernte & Lager",
|
label: "Ernte & Lager",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
{ label: "Ernten", href: "/harvests", icon: Scissors },
|
||||||
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
|
||||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||||
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
||||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||||
|
|||||||
Reference in new issue
Block a user