feat(ui): stock card with withdraw and inventory count, production batches

- Bottling detail shows Abgefüllt / Webshop verkauft / Entnommen / Rest
  (negative rest flagged "Prüfen") and the full movement list; buttons
  "Entnehmen" (reason, note) and "Inventur korrigieren" (counted
  quantity, difference booked).
- Rest column in the bottling list.
- New pages for production batches (list, create, detail with the same
  stock card), sidebar entry with its own permission.
- Products get "Art" (honey vs. manufactured goods) and the webshop
  category; bottlings only offer honey articles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 06:44:21 +02:00
1 parent 04f93e954a
commit f57db97578
21 files changed
+1151 -5

No files matched your search

@@ -12,6 +12,7 @@ import { BottlingEditDialog } from "./bottling-edit-dialog";
import { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" };
@@ -78,6 +79,13 @@ export default function BottlingBatchDetailPage() {
</p>
</CardContent>
</Card>
<StockCard
path="bottling-batches"
id={bottlingBatch.id}
initialLabel="Abgefüllt"
unit="Gläser"
canBook={hasPermission("bottling:withdraw")}
/>
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
</div>
</main>
@@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() {
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
{products?.map((product) => (
{products
?.filter((product) => product.kind !== "MANUFACTURED")
.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
@@ -63,6 +63,13 @@ export default function BottlingBatchesPage() {
label: "Menge",
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
},
{
label: "Rest",
value:
bottlingBatch.remainingCount < 0
? `${bottlingBatch.remainingCount} (prüfen)`
: `${bottlingBatch.remainingCount} Gläser`,
},
{ label: "Datum", value: formatDate(bottlingBatch.date) },
]}
/>
@@ -0,0 +1,85 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deleteProductionBatch, fetchProductionBatch } from "@/lib/production-batches/api";
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: "/production-batches", backLabel: "Alle Herstellungschargen" };
export default function ProductionBatchDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const batchQuery = useQuery({
queryKey: ["production-batches", params.id],
queryFn: () => fetchProductionBatch(params.id),
});
const batch = batchQuery.data;
const deletion = useMutation({
mutationFn: () => deleteProductionBatch(params.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
toast.success("Charge gelöscht");
router.push("/production-batches");
},
onError: (error: Error) => toast.error(error.message || "Charge konnte nicht gelöscht werden"),
});
if (batchQuery.isLoading || batchQuery.isError || !batch) {
return <DetailQueryFallback query={batchQuery} what="Daten der Charge" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Factory}
title={`Charge ${batch.lotNumber}`}
subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
{hasPermission("production-batch:delete") ? (
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
title="Charge löschen"
description={`Charge ${batch.lotNumber} wird entfernt. Das geht nur, solange noch nichts verkauft oder entnommen wurde - danach bleibt die Charge als Historie erhalten.`}
confirmLabel="Endgültig löschen"
onConfirm={() => deletion.mutate()}
isPending={deletion.isPending}
/>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>Artikel: {batch.product.name}</p>
<p>Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}</p>
{batch.bestBeforeDate ? (
<p>Mindesthaltbar bis: {new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}</p>
) : null}
{batch.notes ? <p className="whitespace-pre-line">Notiz: {batch.notes}</p> : null}
</CardContent>
</Card>
<StockCard
path="production-batches"
id={batch.id}
initialLabel="Hergestellt"
unit="Stück"
canBook={hasPermission("production-batch:withdraw")}
/>
</div>
</main>
);
}
@@ -0,0 +1,166 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Factory } from "lucide-react";
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 { 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";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewProductionBatchPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [productId, setProductId] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(today());
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
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 selected = manufactured.find((product) => product.id === productId);
const mutation = useMutation({
mutationFn: () =>
createProductionBatch({
productId,
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate ? new Date(bestBeforeDate).toISOString() : undefined,
notes: notes.trim() || undefined,
}),
onSuccess: (batch) => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
toast.success(`Charge ${batch.lotNumber} angelegt`);
router.push(`/production-batches/${batch.id}`);
},
onError: (mutationError: Error) => {
const message = mutationError.message || "Charge konnte nicht angelegt werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!productId) {
setError("Bitte einen Artikel wählen.");
return;
}
mutation.mutate();
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Factory}
title="Neue Herstellungscharge"
description="Was hast du hergestellt, und wie viele Stück?"
/>
<Card>
<CardContent>
<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 ?? "")}>
<SelectTrigger id="pb-product" className="w-full">
<SelectValue placeholder="Artikel wählen">{selected?.name}</SelectValue>
</SelectTrigger>
<SelectContent>
{manufactured.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
{products && manufactured.length === 0 ? (
<p className="text-xs text-muted-foreground">
Noch kein Artikel der Art &bdquo;Hergestellte Ware&ldquo;. Lege ihn unter Produkte an.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-lot">Chargennummer</Label>
<Input
id="pb-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-date">Herstelldatum</Label>
<Input
id="pb-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-quantity">Hergestellte Stückzahl</Label>
<Input
id="pb-quantity"
type="number"
inputMode="numeric"
min={1}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-best-before">Mindesthaltbar bis (optional)</Label>
<Input
id="pb-best-before"
type="date"
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<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)} />
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Charge anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { Factory } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchProductionBatches } from "@/lib/production-batches/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";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
export default function ProductionBatchesPage() {
const { hasPermission } = useAuth();
const canCreate = hasPermission("production-batch:create");
const batchesQuery = useQuery({
queryKey: ["production-batches"],
queryFn: () => fetchProductionBatches(),
});
const newButton = canCreate ? (
<Button size="touch" nativeButton={false} render={<Link href="/production-batches/new" />}>
+ Neue Herstellungscharge
</Button>
) : null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Factory}
title="Herstellungschargen"
description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest."
actions={newButton}
/>
<QueryState
query={batchesQuery}
what="Herstellungschargen"
empty={
<EmptyState
icon={Factory}
title="Noch keine Herstellungschargen"
description="Lege eine Charge an, sobald du Kerzen, Tinkturen oder Salben hergestellt hast. Der Artikel muss die Art „Hergestellte Ware“ haben."
action={newButton ?? undefined}
/>
}
>
{(batches) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{batches.map((batch) => (
<EntityCard
key={batch.id}
href={`/production-batches/${batch.id}`}
icon={Factory}
title={<span className="font-mono">{batch.lotNumber}</span>}
subtitle={batch.product.name}
fields={[
{ label: "Hergestellt", value: String(batch.quantity) },
{
label: "Rest",
value: batch.remainingCount < 0 ? `${batch.remainingCount} (prüfen)` : String(batch.remainingCount),
},
{ label: "Datum", value: new Date(batch.date).toLocaleDateString("de-DE") },
]}
/>
))}
</div>
)}
</QueryState>
</main>
);
}
@@ -5,8 +5,12 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api";
import { Product, ProductStatus } from "@/lib/products/types";
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels";
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import {
PRODUCT_KIND_LABELS,
PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -37,6 +41,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const [status, setStatus] = useState<ProductStatus>(product.status);
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
const mutation = useMutation({
mutationFn: () =>
@@ -48,6 +53,7 @@ export function EditProductDialog({ product }: { product: Product }) {
weight: weight ? Number(weight) : undefined,
salesUnit: salesUnit || undefined,
status,
shopCategory,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["products"] });
@@ -145,6 +151,29 @@ export function EditProductDialog({ product }: { product: Product }) {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shop-category">Kategorie im Webshop</Label>
<Select
value={shopCategory}
onValueChange={(value) =>
setShopCategory((value ?? shopCategory) as ShopCategory)
}
>
<SelectTrigger id="product-edit-shop-category" className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
<Input
@@ -59,6 +59,8 @@ describe("NewProductPage", () => {
packagingSize: 500,
weight: undefined,
salesUnit: undefined,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
});
expect(push).toHaveBeenCalledWith("/products");
});
@@ -17,6 +17,15 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PRODUCT_KIND_LABELS, SHOP_CATEGORY_LABELS } from "@/lib/products/labels";
import type { ProductKind, ShopCategory } from "@/lib/products/types";
import {
ProductFormErrors,
ProductFormValues,
@@ -121,6 +130,51 @@ export default function NewProductPage() {
/>
)}
</Field>
<Field
label="Art"
hint="Honig wird abgefüllt; Kerzen, Tinkturen und Salben bekommen Herstellungschargen"
>
{({ id, describedBy }) => (
<Select
value={values.kind}
onValueChange={(value) =>
set("kind", (value ?? values.kind) as ProductKind)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{PRODUCT_KIND_LABELS[values.kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(PRODUCT_KIND_LABELS) as ProductKind[]).map((option) => (
<SelectItem key={option} value={option}>
{PRODUCT_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Kategorie im Webshop">
{({ id, describedBy }) => (
<Select
value={values.shopCategory}
onValueChange={(value) =>
set("shopCategory", (value ?? values.shopCategory) as ShopCategory)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[values.shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
{({ id, describedBy }) => (
<Input
@@ -1,4 +1,8 @@
import type { CreateProductInput } from "@/lib/products/types";
import type {
CreateProductInput,
ProductKind,
ShopCategory,
} from "@/lib/products/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ProductFormValues {
@@ -8,6 +12,8 @@ export interface ProductFormValues {
weight: string;
sku: string;
description: string;
kind: ProductKind;
shopCategory: ShopCategory;
}
export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>;
@@ -20,6 +26,8 @@ export function initialProductValues(): ProductFormValues {
weight: "",
sku: "",
description: "",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
};
}
@@ -43,5 +51,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
packagingSize: numberOrUndefined(values.packagingSize),
weight: numberOrUndefined(values.weight),
salesUnit: textOrUndefined(values.salesUnit),
kind: values.kind,
shopCategory: values.shopCategory,
};
}
@@ -8,6 +8,7 @@ import {
Crown,
Database,
Droplet,
Factory,
FileClock,
FlaskConical,
Hexagon,
@@ -81,6 +82,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: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },