feat(frontend): Honigchargen-Ansichten (Modul 11)
Chargenliste mit Anlage-Dialog sowie Detailseite mit Honigmessungen und Rückverfolgbarkeits-Ansicht bis zu den Ursprungsvölkern.
This commit is contained in:
1 parent
cf625552d1
commit
f3d99524ff
8 files changed
+525
-1
No files matched your search
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [waterContent, setWaterContent] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(todayIsoDate());
|
||||
const [device, setDevice] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: { waterContent: number; measuredAt: string; device?: string }) =>
|
||||
addHoneyMeasurement(honeyBatchId, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatchId] });
|
||||
setOpen(false);
|
||||
setWaterContent("");
|
||||
setDevice("");
|
||||
setError(null);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Messung konnte nicht gespeichert werden.");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!waterContent) {
|
||||
setError("Bitte einen Wassergehalt angeben.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
waterContent: Number(waterContent),
|
||||
measuredAt: new Date(measuredAt).toISOString(),
|
||||
device: device || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Honigmessung</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-water">Wassergehalt (%)</Label>
|
||||
<Input
|
||||
id="measurement-water"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
required
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-date">Datum</Label>
|
||||
<Input
|
||||
id="measurement-date"
|
||||
type="date"
|
||||
required
|
||||
value={measuredAt}
|
||||
onChange={(event) => setMeasuredAt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-device">Messgerät</Label>
|
||||
<Input
|
||||
id="measurement-device"
|
||||
value={device}
|
||||
onChange={(event) => setDevice(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AddMeasurementDialog } from "./add-measurement-dialog";
|
||||
|
||||
export default function HoneyBatchDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { data: honeyBatch, isLoading, isError } = useQuery({
|
||||
queryKey: ["honey-batches", params.id],
|
||||
queryFn: () => fetchHoneyBatch(params.id),
|
||||
});
|
||||
const { data: traceability } = useQuery({
|
||||
queryKey: ["honey-batches", params.id, "traceability"],
|
||||
queryFn: () => fetchHoneyBatchTraceability(params.id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||
}
|
||||
if (isError || !honeyBatch) {
|
||||
return (
|
||||
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
|
||||
Honigcharge 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">
|
||||
<CardTitle>{honeyBatch.batchNumber}</CardTitle>
|
||||
<Badge>{honeyBatch.status}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
|
||||
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Honigmessungen</CardTitle>
|
||||
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.measurements.length === 0 ? (
|
||||
<p className="text-muted-foreground">Noch keine Messung dokumentiert.</p>
|
||||
) : null}
|
||||
{honeyBatch.measurements.map((measurement) => (
|
||||
<div key={measurement.id} className="rounded-md border p-3">
|
||||
<p className="font-medium">
|
||||
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
||||
{measurement.waterContent}% Wassergehalt
|
||||
</p>
|
||||
{measurement.device ? <p className="text-muted-foreground">{measurement.device}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Rückverfolgbarkeit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{traceability ? (
|
||||
<>
|
||||
<p>
|
||||
Schleuderung am{" "}
|
||||
{new Date(traceability.extraction.date).toLocaleDateString("de-DE")}
|
||||
{traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{traceability.harvests.map((harvest) => (
|
||||
<div key={harvest.id} className="rounded-md border p-3">
|
||||
<p className="font-medium">
|
||||
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Völker: {harvest.colonies.map((colony) => colony.code).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchExtractions } from "@/lib/extractions/api";
|
||||
import { createHoneyBatch } from "@/lib/honey-batches/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function CreateHoneyBatchDialog() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [batchNumber, setBatchNumber] = useState("");
|
||||
const [extractionId, setExtractionId] = useState("");
|
||||
const [honeyType, setHoneyType] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: extractions } = useQuery({
|
||||
queryKey: ["extractions"],
|
||||
queryFn: fetchExtractions,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createHoneyBatch,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
setOpen(false);
|
||||
setBatchNumber("");
|
||||
setExtractionId("");
|
||||
setHoneyType("");
|
||||
setAmount("");
|
||||
setError(null);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Honigcharge konnte nicht angelegt werden.");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!extractionId) {
|
||||
setError("Bitte eine Schleuderung auswählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
batchNumber,
|
||||
extractionId,
|
||||
honeyType: honeyType || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button>+ Neue Charge</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Honigcharge</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-number">Chargennummer</Label>
|
||||
<Input
|
||||
id="batch-number"
|
||||
required
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-extraction">Schleuderung</Label>
|
||||
<Select value={extractionId} onValueChange={(value) => setExtractionId(value ?? "")}>
|
||||
<SelectTrigger id="batch-extraction">
|
||||
<SelectValue placeholder="Schleuderung wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{extractions?.map((extraction) => (
|
||||
<SelectItem key={extraction.id} value={extraction.id}>
|
||||
{new Date(extraction.date).toLocaleDateString("de-DE")} ·{" "}
|
||||
{extraction.harvests
|
||||
.flatMap((entry) => entry.harvest.colonies.map((c) => c.colony.code))
|
||||
.join(", ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-honey-type">Honigsorte</Label>
|
||||
<Input
|
||||
id="batch-honey-type"
|
||||
value={honeyType}
|
||||
onChange={(event) => setHoneyType(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-amount">Menge (kg)</Label>
|
||||
<Input
|
||||
id="batch-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
|
||||
|
||||
export default function HoneyBatchesPage() {
|
||||
const { data: honeyBatches, isLoading, isError } = useQuery({
|
||||
queryKey: ["honey-batches"],
|
||||
queryFn: fetchHoneyBatches,
|
||||
});
|
||||
|
||||
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">Honigchargen</h1>
|
||||
<CreateHoneyBatchDialog />
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
Honigchargen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{honeyBatches ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Chargennummer</TableHead>
|
||||
<TableHead>Sorte</TableHead>
|
||||
<TableHead>Menge</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{honeyBatches.map((honeyBatch) => (
|
||||
<TableRow key={honeyBatch.id}>
|
||||
<TableCell>
|
||||
<Link className="underline" href={`/honey-batches/${honeyBatch.id}`}>
|
||||
{honeyBatch.batchNumber}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{honeyBatch.honeyType ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{honeyBatch.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user