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>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/tasks">
|
||||
Aufgaben
|
||||
</Link>
|
||||
<Link
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
href="/honey-batches"
|
||||
>
|
||||
Honigchargen
|
||||
</Link>
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
CreateHoneyBatchInput,
|
||||
CreateHoneyMeasurementInput,
|
||||
HoneyBatch,
|
||||
Traceability,
|
||||
} from "./types";
|
||||
|
||||
export function fetchHoneyBatches(): Promise<HoneyBatch[]> {
|
||||
return apiFetch<HoneyBatch[]>("/honey-batches");
|
||||
}
|
||||
|
||||
export function fetchHoneyBatch(id: string): Promise<HoneyBatch> {
|
||||
return apiFetch<HoneyBatch>(`/honey-batches/${id}`);
|
||||
}
|
||||
|
||||
export function fetchHoneyBatchTraceability(id: string): Promise<Traceability> {
|
||||
return apiFetch<Traceability>(`/honey-batches/${id}/traceability`);
|
||||
}
|
||||
|
||||
export function createHoneyBatch(input: CreateHoneyBatchInput): Promise<HoneyBatch> {
|
||||
return apiFetch<HoneyBatch>("/honey-batches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function addHoneyMeasurement(
|
||||
id: string,
|
||||
input: CreateHoneyMeasurementInput,
|
||||
): Promise<HoneyBatch> {
|
||||
return apiFetch<HoneyBatch>(`/honey-batches/${id}/measurements`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export type HoneyBatchStatus =
|
||||
| "RAW"
|
||||
| "EXTRACTED"
|
||||
| "CLARIFIED"
|
||||
| "CREAMED"
|
||||
| "STORED"
|
||||
| "BOTTLED"
|
||||
| "SOLD"
|
||||
| "CONSUMED";
|
||||
|
||||
export interface HoneyMeasurement {
|
||||
id: string;
|
||||
waterContent: number;
|
||||
measuredAt: string;
|
||||
device: string | null;
|
||||
temperature: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface HoneyBatch {
|
||||
id: string;
|
||||
batchNumber: string;
|
||||
extractionId: string;
|
||||
honeyType: string | null;
|
||||
amount: number | null;
|
||||
waterContent: number | null;
|
||||
storageContainerId: string | null;
|
||||
status: HoneyBatchStatus;
|
||||
createdAt: string;
|
||||
measurements: HoneyMeasurement[];
|
||||
}
|
||||
|
||||
export interface Traceability {
|
||||
honeyBatch: {
|
||||
id: string;
|
||||
batchNumber: string;
|
||||
honeyType: string | null;
|
||||
amount: number | null;
|
||||
status: HoneyBatchStatus;
|
||||
};
|
||||
extraction: {
|
||||
id: string;
|
||||
date: string;
|
||||
room: string | null;
|
||||
};
|
||||
harvests: {
|
||||
id: string;
|
||||
date: string;
|
||||
apiaryId: string;
|
||||
colonies: { id: string; code: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CreateHoneyBatchInput {
|
||||
batchNumber: string;
|
||||
extractionId: string;
|
||||
honeyType?: string;
|
||||
amount?: number;
|
||||
waterContent?: number;
|
||||
}
|
||||
|
||||
export interface CreateHoneyMeasurementInput {
|
||||
waterContent: number;
|
||||
measuredAt: string;
|
||||
device?: string;
|
||||
temperature?: number;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
||||
- Endpunkte: `GET/POST /api/v1/honey-batches`, `GET /api/v1/honey-batches/:id`, `GET /api/v1/honey-batches/:id/traceability`, `POST /api/v1/honey-batches/:id/measurements`
|
||||
- Frontend: `/honey-batches`, `/honey-batches/[id]` (inkl. Rückverfolgbarkeits-Ansicht)
|
||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
||||
- [ ] Frontend-Views fertig, getestet, grün, committet
|
||||
- [x] Frontend-Views fertig, getestet, grün, committet
|
||||
|
||||
### 12. Grundlegende Lagerverwaltung
|
||||
|
||||
|
||||
Reference in new issue
Block a user