feat(ui): raw material lots and production inputs
- Pages for raw material lots (beeswax, propolis): list, create in grams
with optional apiary, detail with stock card (harvested / used in
production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f9899d5621
commit
569a43d247
22 files changed
+803
-412
No files matched your search
@@ -15,6 +15,10 @@ import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { StockCard } from "@/components/stock/stock-card";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { EditProductionBatchDialog } from "./edit-production-batch-dialog";
|
||||
import {
|
||||
RAW_MATERIAL_KIND_LABELS,
|
||||
formatGrams,
|
||||
} from "@/lib/raw-materials/labels";
|
||||
|
||||
const BACK = {
|
||||
backHref: "/production-batches",
|
||||
@@ -102,6 +106,25 @@ export default function ProductionBatchDetailPage() {
|
||||
{batch.notes ? (
|
||||
<p className="whitespace-pre-line">Notiz: {batch.notes}</p>
|
||||
) : null}
|
||||
{batch.inputs && batch.inputs.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">Hergestellt aus</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{batch.inputs.map((input) => (
|
||||
<li key={input.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/raw-materials/${input.rawMaterialLot.id}`}
|
||||
>
|
||||
{RAW_MATERIAL_KIND_LABELS[input.rawMaterialLot.kind]}{" "}
|
||||
{input.rawMaterialLot.lotNumber}
|
||||
</Link>
|
||||
: {formatGrams(input.quantity)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StockCard
|
||||
|
||||
@@ -7,6 +7,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createProductionBatch } from "@/lib/production-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
|
||||
import {
|
||||
RAW_MATERIAL_KIND_LABELS,
|
||||
formatGrams,
|
||||
} from "@/lib/raw-materials/labels";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,13 +39,31 @@ export default function NewProductionBatchPage() {
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
// Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm.
|
||||
const [inputs, setInputs] = useState<
|
||||
{ rawMaterialLotId: string; grams: string }[]
|
||||
>([]);
|
||||
const { data: rawLots } = useQuery({
|
||||
queryKey: ["raw-material-lots"],
|
||||
queryFn: () => fetchRawMaterialLots(),
|
||||
});
|
||||
const availableLots = (rawLots ?? []).filter((lot) => lot.remainingCount > 0);
|
||||
const updateInput = (
|
||||
index: number,
|
||||
change: Partial<{ rawMaterialLotId: string; grams: string }>,
|
||||
) =>
|
||||
setInputs((current) =>
|
||||
current.map((row, i) => (i === index ? { ...row, ...change } : row)),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||
});
|
||||
const manufactured = (products ?? []).filter((product) => product.kind === "MANUFACTURED");
|
||||
const manufactured = (products ?? []).filter(
|
||||
(product) => product.kind === "MANUFACTURED",
|
||||
);
|
||||
const selected = manufactured.find((product) => product.id === productId);
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -50,8 +73,16 @@ export default function NewProductionBatchPage() {
|
||||
lotNumber: lotNumber.trim(),
|
||||
date: new Date(date).toISOString(),
|
||||
quantity: Number(quantity),
|
||||
bestBeforeDate: bestBeforeDate ? new Date(bestBeforeDate).toISOString() : undefined,
|
||||
bestBeforeDate: bestBeforeDate
|
||||
? new Date(bestBeforeDate).toISOString()
|
||||
: undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
inputs: inputs
|
||||
.filter((row) => row.rawMaterialLotId && Number(row.grams) > 0)
|
||||
.map((row) => ({
|
||||
rawMaterialLotId: row.rawMaterialLotId,
|
||||
quantity: Number(row.grams),
|
||||
})),
|
||||
}),
|
||||
onSuccess: (batch) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
|
||||
@@ -59,7 +90,8 @@ export default function NewProductionBatchPage() {
|
||||
router.push(`/production-batches/${batch.id}`);
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message = mutationError.message || "Charge konnte nicht angelegt werden";
|
||||
const message =
|
||||
mutationError.message || "Charge konnte nicht angelegt werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
@@ -84,12 +116,20 @@ export default function NewProductionBatchPage() {
|
||||
/>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form className="flex max-w-xl flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<form
|
||||
className="flex max-w-xl flex-col gap-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-product">Artikel</Label>
|
||||
<Select value={productId} onValueChange={(value) => setProductId(value ?? "")}>
|
||||
<Select
|
||||
value={productId}
|
||||
onValueChange={(value) => setProductId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="pb-product" className="w-full">
|
||||
<SelectValue placeholder="Artikel wählen">{selected?.name}</SelectValue>
|
||||
<SelectValue placeholder="Artikel wählen">
|
||||
{selected?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manufactured.map((product) => (
|
||||
@@ -101,7 +141,8 @@ export default function NewProductionBatchPage() {
|
||||
</Select>
|
||||
{products && manufactured.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Noch kein Artikel der Art „Hergestellte Ware“. Lege ihn unter Produkte an.
|
||||
Noch kein Artikel der Art „Hergestellte Ware“.
|
||||
Lege ihn unter Produkte an.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -138,7 +179,9 @@ export default function NewProductionBatchPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-best-before">Mindesthaltbar bis (optional)</Label>
|
||||
<Label htmlFor="pb-best-before">
|
||||
Mindesthaltbar bis (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="pb-best-before"
|
||||
type="date"
|
||||
@@ -146,9 +189,108 @@ export default function NewProductionBatchPage() {
|
||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<legend className="px-1 text-sm font-medium">
|
||||
Verbrauchte Rohstoffe (optional)
|
||||
</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wird beim Anlegen vom Rohstoff abgebucht. So lässt sich später
|
||||
nachverfolgen, aus welchem Wachs oder Propolis diese Charge
|
||||
stammt.
|
||||
</p>
|
||||
{inputs.map((row, index) => {
|
||||
const lot = availableLots.find(
|
||||
(candidate) => candidate.id === row.rawMaterialLotId,
|
||||
);
|
||||
return (
|
||||
<div key={index} className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-48 flex-1 flex-col gap-1">
|
||||
<Label htmlFor={`pb-input-lot-${index}`}>Rohstoff</Label>
|
||||
<Select
|
||||
value={row.rawMaterialLotId}
|
||||
onValueChange={(value) =>
|
||||
updateInput(index, { rawMaterialLotId: value ?? "" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`pb-input-lot-${index}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Charge wählen">
|
||||
{lot
|
||||
? `${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`
|
||||
: undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableLots.map((candidate) => (
|
||||
<SelectItem key={candidate.id} value={candidate.id}>
|
||||
{RAW_MATERIAL_KIND_LABELS[candidate.kind]}{" "}
|
||||
{candidate.lotNumber} (noch{" "}
|
||||
{formatGrams(candidate.remainingCount)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-28 flex-col gap-1">
|
||||
<Label htmlFor={`pb-input-grams-${index}`}>Gramm</Label>
|
||||
<Input
|
||||
id={`pb-input-grams-${index}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={lot?.remainingCount}
|
||||
step={1}
|
||||
value={row.grams}
|
||||
onChange={(event) =>
|
||||
updateInput(index, { grams: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setInputs((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
disabled={availableLots.length === 0}
|
||||
onClick={() =>
|
||||
setInputs((current) => [
|
||||
...current,
|
||||
{ rawMaterialLotId: "", grams: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
+ Rohstoff hinzufügen
|
||||
</Button>
|
||||
{rawLots && availableLots.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kein Rohstoff mit Restbestand vorhanden.
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-notes">Notiz (optional, intern)</Label>
|
||||
<Textarea id="pb-notes" value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||
<Textarea
|
||||
id="pb-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
|
||||
@@ -20,7 +20,11 @@ export default function ProductionBatchesPage() {
|
||||
});
|
||||
|
||||
const newButton = canCreate ? (
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/production-batches/new" />}>
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href="/production-batches/new" />}
|
||||
>
|
||||
+ Neue Herstellungscharge
|
||||
</Button>
|
||||
) : null;
|
||||
@@ -59,9 +63,15 @@ export default function ProductionBatchesPage() {
|
||||
{ label: "Hergestellt", value: String(batch.quantity) },
|
||||
{
|
||||
label: "Rest",
|
||||
value: batch.remainingCount < 0 ? `${batch.remainingCount} (prüfen)` : String(batch.remainingCount),
|
||||
value:
|
||||
batch.remainingCount < 0
|
||||
? `${batch.remainingCount} (prüfen)`
|
||||
: String(batch.remainingCount),
|
||||
},
|
||||
{
|
||||
label: "Datum",
|
||||
value: new Date(batch.date).toLocaleDateString("de-DE"),
|
||||
},
|
||||
{ label: "Datum", value: new Date(batch.date).toLocaleDateString("de-DE") },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteRawMaterialLot,
|
||||
fetchRawMaterialLot,
|
||||
} from "@/lib/raw-materials/api";
|
||||
import {
|
||||
RAW_MATERIAL_KIND_LABELS,
|
||||
formatGrams,
|
||||
} from "@/lib/raw-materials/labels";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { StockCard } from "@/components/stock/stock-card";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
const BACK = { backHref: "/raw-materials", backLabel: "Alle Rohstoffe" };
|
||||
|
||||
export default function RawMaterialDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
const lotQuery = useQuery({
|
||||
queryKey: ["raw-material-lots", params.id],
|
||||
queryFn: () => fetchRawMaterialLot(params.id),
|
||||
});
|
||||
const lot = lotQuery.data;
|
||||
|
||||
const deletion = useMutation({
|
||||
mutationFn: () => deleteRawMaterialLot(params.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
|
||||
toast.success("Rohstoff gelöscht");
|
||||
router.push("/raw-materials");
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Rohstoff konnte nicht gelöscht werden"),
|
||||
});
|
||||
|
||||
if (lotQuery.isLoading || lotQuery.isError || !lot) {
|
||||
return (
|
||||
<DetailQueryFallback
|
||||
query={lotQuery}
|
||||
what="Daten des Rohstoffs"
|
||||
{...BACK}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Boxes}
|
||||
title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`}
|
||||
subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`}
|
||||
{...BACK}
|
||||
/>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
{hasPermission("raw-material:delete") ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title="Rohstoff löschen"
|
||||
description={`${lot.lotNumber} wird entfernt. Das geht nur, solange nichts verbraucht, entnommen oder inventiert wurde.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={() => deletion.mutate()}
|
||||
isPending={deletion.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<p>Art: {RAW_MATERIAL_KIND_LABELS[lot.kind]}</p>
|
||||
{lot.apiary ? <p>Stand: {lot.apiary.name}</p> : null}
|
||||
{lot.notes ? (
|
||||
<p className="whitespace-pre-line">Notiz: {lot.notes}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verwendet in</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lot.usedIn.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch in keiner Herstellungscharge verwendet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y rounded-lg border text-sm">
|
||||
{lot.usedIn.map((use) => (
|
||||
<li key={use.id}>
|
||||
<Link
|
||||
href={`/production-batches/${use.productionBatch.id}`}
|
||||
className="flex items-baseline justify-between gap-2 p-3 hover:bg-muted"
|
||||
>
|
||||
<span>
|
||||
<span className="font-mono font-medium">
|
||||
{use.productionBatch.lotNumber}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{use.productionBatch.product.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatGrams(use.quantity)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<StockCard
|
||||
path="raw-material-lots"
|
||||
id={lot.id}
|
||||
initialLabel="Gewonnen"
|
||||
unit="g"
|
||||
canBook={hasPermission("raw-material:withdraw")}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createRawMaterialLot } from "@/lib/raw-materials/api";
|
||||
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
|
||||
import { RawMaterialKind } from "@/lib/raw-materials/types";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const NO_APIARY = "__none__";
|
||||
|
||||
export default function NewRawMaterialPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
|
||||
const [lotNumber, setLotNumber] = useState("");
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [apiaryId, setApiaryId] = useState(NO_APIARY);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries"],
|
||||
queryFn: () => fetchApiaries(),
|
||||
});
|
||||
const selectedApiary = apiaries?.find((apiary) => apiary.id === apiaryId);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createRawMaterialLot({
|
||||
kind,
|
||||
lotNumber: lotNumber.trim(),
|
||||
date: new Date(date).toISOString(),
|
||||
quantity: Number(quantity),
|
||||
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
|
||||
notes: notes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (lot) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
|
||||
toast.success(`Rohstoff ${lot.lotNumber} angelegt`);
|
||||
router.push(`/raw-materials/${lot.id}`);
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message =
|
||||
mutationError.message || "Rohstoff konnte nicht angelegt werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Boxes}
|
||||
title="Neuer Rohstoff"
|
||||
description="Was hast du gewonnen, und wie viel?"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form
|
||||
className="flex max-w-xl flex-col gap-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-kind">Art</Label>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) =>
|
||||
setKind((value ?? kind) as RawMaterialKind)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="raw-kind" className="w-full">
|
||||
<SelectValue>{RAW_MATERIAL_KIND_LABELS[kind]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
Object.keys(RAW_MATERIAL_KIND_LABELS) as RawMaterialKind[]
|
||||
).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{RAW_MATERIAL_KIND_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-lot">Chargennummer</Label>
|
||||
<Input
|
||||
id="raw-lot"
|
||||
required
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-date">Gewonnen am</Label>
|
||||
<Input
|
||||
id="raw-date"
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-quantity">Menge (g)</Label>
|
||||
<Input
|
||||
id="raw-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
required
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
In Gramm, z. B. 4200 für 4,2 kg Wachs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-apiary">Stand (optional)</Label>
|
||||
<Select
|
||||
value={apiaryId}
|
||||
onValueChange={(value) => setApiaryId(value ?? NO_APIARY)}
|
||||
>
|
||||
<SelectTrigger id="raw-apiary" className="w-full">
|
||||
<SelectValue>
|
||||
{selectedApiary?.name ?? "Kein Stand"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_APIARY}>Kein Stand</SelectItem>
|
||||
{(apiaries ?? []).map((apiary) => (
|
||||
<SelectItem key={apiary.id} value={apiary.id}>
|
||||
{apiary.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="raw-notes">Notiz (optional, intern)</Label>
|
||||
<Textarea
|
||||
id="raw-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Rohstoff anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
|
||||
import {
|
||||
RAW_MATERIAL_KIND_LABELS,
|
||||
formatGrams,
|
||||
} from "@/lib/raw-materials/labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
export default function RawMaterialsPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const lotsQuery = useQuery({
|
||||
queryKey: ["raw-material-lots"],
|
||||
queryFn: () => fetchRawMaterialLots(),
|
||||
});
|
||||
const newButton = hasPermission("raw-material:create") ? (
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href="/raw-materials/new" />}
|
||||
>
|
||||
+ Neuer Rohstoff
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Boxes}
|
||||
title="Rohstoffe"
|
||||
description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest."
|
||||
actions={newButton}
|
||||
/>
|
||||
<QueryState
|
||||
query={lotsQuery}
|
||||
what="Rohstoffe"
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Noch keine Rohstoffe"
|
||||
description="Erfasse Wachs vom Entdeckeln oder Propolis von den Gittern, um daraus Kerzen, Tinkturen oder Salben herzustellen."
|
||||
action={newButton ?? undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(lots) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{lots.map((lot) => (
|
||||
<EntityCard
|
||||
key={lot.id}
|
||||
href={`/raw-materials/${lot.id}`}
|
||||
icon={Boxes}
|
||||
title={<span className="font-mono">{lot.lotNumber}</span>}
|
||||
subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]}
|
||||
fields={[
|
||||
{ label: "Gewonnen", value: formatGrams(lot.quantity) },
|
||||
{
|
||||
label: "Rest",
|
||||
value:
|
||||
lot.remainingCount < 0
|
||||
? `${formatGrams(lot.remainingCount)} (prüfen)`
|
||||
: formatGrams(lot.remainingCount),
|
||||
},
|
||||
{
|
||||
label: "Datum",
|
||||
value: new Date(lot.date).toLocaleDateString("de-DE"),
|
||||
},
|
||||
{
|
||||
label: "Verwendet in",
|
||||
value: `${lot.usedIn.length} Charge(n)`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Database,
|
||||
Droplet,
|
||||
Factory,
|
||||
Boxes,
|
||||
FileClock,
|
||||
FlaskConical,
|
||||
Hexagon,
|
||||
@@ -82,6 +83,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
|
||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
||||
{
|
||||
label: "Rohstoffe",
|
||||
href: "/raw-materials",
|
||||
icon: Boxes,
|
||||
permission: "raw-material:read",
|
||||
},
|
||||
{
|
||||
label: "Herstellungschargen",
|
||||
href: "/production-batches",
|
||||
@@ -128,7 +135,12 @@ const ADMIN_GROUP: NavGroup = {
|
||||
icon: Database,
|
||||
permission: ["backup:export", "backup:import", "import:execute"],
|
||||
},
|
||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
|
||||
{
|
||||
label: "Audit-Log",
|
||||
href: "/settings/audit-log",
|
||||
icon: History,
|
||||
permission: "audit:read",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -157,7 +169,10 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasNavPermission(user: AuthUser, permission: NavItem["permission"]): boolean {
|
||||
function hasNavPermission(
|
||||
user: AuthUser,
|
||||
permission: NavItem["permission"],
|
||||
): boolean {
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
@@ -185,13 +200,19 @@ export function Sidebar({ user }: { user: AuthUser }) {
|
||||
className="flex size-8 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<Hexagon className="size-4.5 fill-white/25 text-white" strokeWidth={2.25} />
|
||||
<Hexagon
|
||||
className="size-4.5 fill-white/25 text-white"
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</span>
|
||||
ApiCore
|
||||
</Link>
|
||||
<nav className="flex flex-1 flex-col gap-5">
|
||||
{groups.map((group, index) => (
|
||||
<div key={group.label ?? `group-${index}`} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
key={group.label ?? `group-${index}`}
|
||||
className="flex flex-col gap-0.5"
|
||||
>
|
||||
{group.label ? (
|
||||
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
|
||||
{group.label}
|
||||
|
||||
Reference in new issue
Block a user