diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
index db4866b..50c02d6 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
@@ -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() {
+
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
index 498108a..a3a5c90 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx
@@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() {
- {products?.map((product) => (
+ {products
+ ?.filter((product) => product.kind !== "MANUFACTURED")
+ .map((product) => (
{product.name}
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx
index ad9a778..5c3e8d8 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx
@@ -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) },
]}
/>
diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx
new file mode 100644
index 0000000..0db409b
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx
@@ -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 ;
+ }
+
+ return (
+
+
+
+
+
+ Angaben
+ {hasPermission("production-batch:delete") ? (
+ deletion.mutate()}
+ isPending={deletion.isPending}
+ />
+ ) : null}
+
+
+ Artikel: {batch.product.name}
+ Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}
+ {batch.bestBeforeDate ? (
+ Mindesthaltbar bis: {new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}
+ ) : null}
+ {batch.notes ? Notiz: {batch.notes}
: null}
+
+
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx
new file mode 100644
index 0000000..f4fe2c1
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx
@@ -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(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) {
+ event.preventDefault();
+ setError(null);
+ if (!productId) {
+ setError("Bitte einen Artikel wählen.");
+ return;
+ }
+ mutation.mutate();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/page.tsx
new file mode 100644
index 0000000..5ad6fb9
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/production-batches/page.tsx
@@ -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 ? (
+ }>
+ + Neue Herstellungscharge
+
+ ) : null;
+
+ return (
+
+
+
+
+ }
+ >
+ {(batches) => (
+
+ {batches.map((batch) => (
+ {batch.lotNumber}}
+ 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") },
+ ]}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
index 14df1d9..266faa3 100644
--- a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
@@ -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(product.status);
+ const [shopCategory, setShopCategory] = useState(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 }) {
+
+
+
+
+ Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
+
+
{
packagingSize: 500,
weight: undefined,
salesUnit: undefined,
+ kind: "BOTTLED_HONEY",
+ shopCategory: "HONIG",
});
expect(push).toHaveBeenCalledWith("/products");
});
diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
index b57f389..4baf06f 100644
--- a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
@@ -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() {
/>
)}
+
+ {({ id, describedBy }) => (
+
+ )}
+
+
+ {({ id, describedBy }) => (
+
+ )}
+
{({ id, describedBy }) => (
>;
@@ -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,
};
}
diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx
index e0d53f0..8edddcb 100644
--- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx
+++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx
@@ -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 },
diff --git a/ApiCore-Frontend/src/components/stock/stock-card.spec.tsx b/ApiCore-Frontend/src/components/stock/stock-card.spec.tsx
new file mode 100644
index 0000000..1f6d78d
--- /dev/null
+++ b/ApiCore-Frontend/src/components/stock/stock-card.spec.tsx
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { StockCard } from "./stock-card";
+import { fetchStock } from "@/lib/stock/api";
+import type { BatchStock } from "@/lib/stock/types";
+
+vi.mock("@/lib/stock/api", () => ({
+ fetchStock: vi.fn(),
+ withdrawStock: vi.fn(),
+ correctStock: vi.fn(),
+}));
+
+function renderCard(canBook = true) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+function stock(remaining: number): BatchStock {
+ return {
+ summary: {
+ initial: 120,
+ soldInShop: 30,
+ withdrawn: 10,
+ corrected: 0,
+ remaining,
+ },
+ movements: [
+ {
+ id: "m1",
+ type: "SHOP_SALE",
+ quantity: -30,
+ reason: null,
+ notes: null,
+ apiKeyId: "k1",
+ createdAt: "2026-10-01T10:00:00.000Z",
+ user: null,
+ shopRequest: { externalId: "x", kind: "SALE" },
+ },
+ {
+ id: "m2",
+ type: "WITHDRAWAL",
+ quantity: -10,
+ reason: "HOFLADEN",
+ notes: null,
+ apiKeyId: null,
+ createdAt: "2026-10-01T11:00:00.000Z",
+ user: { id: "u1", name: "Nick" },
+ shopRequest: null,
+ },
+ ],
+ };
+}
+
+describe("StockCard", () => {
+ beforeEach(() => vi.mocked(fetchStock).mockReset());
+
+ it("separates bottled, sold, withdrawn and remaining", async () => {
+ vi.mocked(fetchStock).mockResolvedValue(stock(80));
+ renderCard();
+
+ expect(await screen.findByText("Abgefüllt")).toBeInTheDocument();
+ expect(screen.getByText("120")).toBeInTheDocument();
+ expect(screen.getByText("Webshop verkauft")).toBeInTheDocument();
+ expect(screen.getByText("80")).toBeInTheDocument();
+ expect(screen.queryByText("Prüfen")).not.toBeInTheDocument();
+ });
+
+ it("lists shop sales as coming from the shop and withdrawals with their reason", async () => {
+ vi.mocked(fetchStock).mockResolvedValue(stock(80));
+ renderCard();
+
+ expect(await screen.findByText("Webshop-Verkauf")).toBeInTheDocument();
+ expect(screen.getByText(/· Webshop$/)).toBeInTheDocument();
+ expect(screen.getByText("Entnahme · Hofladen")).toBeInTheDocument();
+ });
+
+ it("flags a negative remaining stock for review", async () => {
+ vi.mocked(fetchStock).mockResolvedValue(stock(-2));
+ renderCard();
+
+ expect(await screen.findByText("Prüfen")).toBeInTheDocument();
+ });
+
+ it("shows the booking buttons only with permission", async () => {
+ vi.mocked(fetchStock).mockResolvedValue(stock(80));
+ renderCard(false);
+
+ await screen.findByText("Abgefüllt");
+ expect(
+ screen.queryByRole("button", { name: /Entnehmen/ }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /Inventur/ }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/components/stock/stock-card.tsx b/ApiCore-Frontend/src/components/stock/stock-card.tsx
new file mode 100644
index 0000000..f31ed97
--- /dev/null
+++ b/ApiCore-Frontend/src/components/stock/stock-card.tsx
@@ -0,0 +1,406 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import { ClipboardCheck, PackageMinus } from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
+import {
+ MOVEMENT_TYPE_LABELS,
+ WITHDRAWAL_REASON_LABELS,
+} from "@/lib/stock/labels";
+import {
+ StockBatchPath,
+ StockMovement,
+ StockSummary,
+ WithdrawalReason,
+} from "@/lib/stock/types";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+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";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+interface StockCardProps {
+ path: StockBatchPath;
+ id: string;
+ /** "Abgefüllt" for bottlings, "Hergestellt" for production batches. */
+ initialLabel: string;
+ /** Unit for counts, e.g. "Gläser" or "Stück". */
+ unit: string;
+ canBook: boolean;
+}
+
+/**
+ * Remaining stock of one batch: bottled/produced vs. what the shop sold, what
+ * was withdrawn here and inventory corrections - plus the two ways to book a
+ * change by hand. The numbers come from the movement ledger; nothing here is
+ * stored separately, so they cannot drift.
+ */
+export function StockCard({ path, id, initialLabel, unit, canBook }: StockCardProps) {
+ const stockQuery = useQuery({
+ queryKey: ["stock", path, id],
+ queryFn: () => fetchStock(path, id),
+ });
+ const stock = stockQuery.data;
+
+ return (
+
+
+ Bestand
+ {canBook && stock ? (
+
+
+
+
+ ) : null}
+
+
+ {stockQuery.isError ? (
+
+ Bestand konnte nicht geladen werden.
+
+ ) : !stock ? (
+ Lädt…
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
+
+function SummaryGrid({
+ summary,
+ initialLabel,
+}: {
+ summary: StockSummary;
+ initialLabel: string;
+}) {
+ const needsReview = summary.remaining < 0;
+ const items = [
+ { label: initialLabel, value: summary.initial },
+ { label: "Webshop verkauft", value: summary.soldInShop },
+ { label: "Entnommen", value: summary.withdrawn },
+ ];
+ return (
+
+ {items.map((item) => (
+
+
- {item.label}
+ - {item.value}
+
+ ))}
+
+
-
+ Rest
+ {needsReview ? Prüfen : null}
+
+
- {summary.remaining}
+ {summary.corrected !== 0 ? (
+
+ inkl. Inventur {summary.corrected > 0 ? "+" : ""}
+ {summary.corrected}
+
+ ) : null}
+
+
+ );
+}
+
+function MovementList({ movements }: { movements: StockMovement[] }) {
+ if (movements.length === 0) {
+ return (
+
+ Noch keine Bewegungen - der Rest entspricht der Ausgangsmenge.
+
+ );
+ }
+ return (
+
+ );
+}
+
+function useStockMutationDone(path: StockBatchPath, id: string) {
+ const queryClient = useQueryClient();
+ return () => {
+ void queryClient.invalidateQueries({ queryKey: ["stock", path, id] });
+ void queryClient.invalidateQueries({ queryKey: [path] });
+ };
+}
+
+function WithdrawDialog({
+ path,
+ id,
+ unit,
+ remaining,
+}: {
+ path: StockBatchPath;
+ id: string;
+ unit: string;
+ remaining: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const [quantity, setQuantity] = useState("1");
+ const [reason, setReason] = useState("HOFLADEN");
+ const [notes, setNotes] = useState("");
+ const [error, setError] = useState(null);
+ const done = useStockMutationDone(path, id);
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ withdrawStock(path, id, {
+ quantity: Number(quantity),
+ reason,
+ notes: notes.trim() || undefined,
+ }),
+ onSuccess: () => {
+ done();
+ toast.success(`${quantity} ${unit} entnommen`);
+ setOpen(false);
+ setQuantity("1");
+ setNotes("");
+ },
+ onError: (mutationError: Error) => {
+ const message = mutationError.message || "Entnahme konnte nicht gebucht werden";
+ setError(message);
+ toast.error(message);
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setError(null);
+ mutation.mutate();
+ }
+
+ return (
+
+ );
+}
+
+function CorrectDialog({
+ path,
+ id,
+ unit,
+ remaining,
+}: {
+ path: StockBatchPath;
+ id: string;
+ unit: string;
+ remaining: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const [counted, setCounted] = useState(String(Math.max(remaining, 0)));
+ const [notes, setNotes] = useState("");
+ const [error, setError] = useState(null);
+ const done = useStockMutationDone(path, id);
+ const difference = counted === "" ? 0 : Number(counted) - remaining;
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ correctStock(path, id, { countedQuantity: Number(counted), notes: notes.trim() }),
+ onSuccess: () => {
+ done();
+ toast.success("Inventur gebucht");
+ setOpen(false);
+ setNotes("");
+ },
+ onError: (mutationError: Error) => {
+ const message = mutationError.message || "Inventur konnte nicht gebucht werden";
+ setError(message);
+ toast.error(message);
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setError(null);
+ mutation.mutate();
+ }
+
+ return (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/bottling/types.ts b/ApiCore-Frontend/src/lib/bottling/types.ts
index f503cd3..fa87e4e 100644
--- a/ApiCore-Frontend/src/lib/bottling/types.ts
+++ b/ApiCore-Frontend/src/lib/bottling/types.ts
@@ -9,6 +9,8 @@ export interface BottlingBatch {
bestBeforeDate: string;
lotNumber: string;
createdAt: string;
+ /** jarCount plus all stock movements (shop sales, withdrawals, ...). */
+ remainingCount: number;
honeyBatch: { id: string; batchNumber: string };
product: { id: string; name: string } | null;
}
diff --git a/ApiCore-Frontend/src/lib/production-batches/api.ts b/ApiCore-Frontend/src/lib/production-batches/api.ts
new file mode 100644
index 0000000..0b90c8b
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/production-batches/api.ts
@@ -0,0 +1,36 @@
+import { apiFetch } from "@/lib/auth/api-client";
+import {
+ CreateProductionBatchInput,
+ ProductionBatch,
+ ProductionBatchFilter,
+} from "./types";
+
+export function fetchProductionBatches(
+ filter: ProductionBatchFilter = {},
+): Promise {
+ const params = new URLSearchParams();
+ if (filter.productId) params.set("productId", filter.productId);
+ const query = params.toString();
+ return apiFetch(
+ `/production-batches${query ? `?${query}` : ""}`,
+ );
+}
+
+export function fetchProductionBatch(id: string): Promise {
+ return apiFetch(`/production-batches/${id}`);
+}
+
+export function createProductionBatch(
+ input: CreateProductionBatchInput,
+): Promise {
+ return apiFetch("/production-batches", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function deleteProductionBatch(id: string): Promise<{ id: string }> {
+ return apiFetch<{ id: string }>(`/production-batches/${id}`, {
+ method: "DELETE",
+ });
+}
diff --git a/ApiCore-Frontend/src/lib/production-batches/types.ts b/ApiCore-Frontend/src/lib/production-batches/types.ts
new file mode 100644
index 0000000..c8a21a5
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/production-batches/types.ts
@@ -0,0 +1,25 @@
+export interface ProductionBatch {
+ id: string;
+ productId: string;
+ lotNumber: string;
+ date: string;
+ quantity: number;
+ bestBeforeDate: string | null;
+ notes: string | null;
+ createdAt: string;
+ remainingCount: number;
+ product: { id: string; name: string };
+}
+
+export interface CreateProductionBatchInput {
+ productId: string;
+ lotNumber: string;
+ date: string;
+ quantity: number;
+ bestBeforeDate?: string;
+ notes?: string;
+}
+
+export interface ProductionBatchFilter {
+ productId?: string;
+}
diff --git a/ApiCore-Frontend/src/lib/products/labels.ts b/ApiCore-Frontend/src/lib/products/labels.ts
index 4265900..70298ea 100644
--- a/ApiCore-Frontend/src/lib/products/labels.ts
+++ b/ApiCore-Frontend/src/lib/products/labels.ts
@@ -1,6 +1,18 @@
-import { ProductStatus } from "./types";
+import { ProductKind, ProductStatus, ShopCategory } from "./types";
export const PRODUCT_STATUS_LABELS: Record = {
ACTIVE: "Aktiv",
DISCONTINUED: "Eingestellt",
};
+
+export const PRODUCT_KIND_LABELS: Record = {
+ BOTTLED_HONEY: "Honig (Bestand je Abfüllung)",
+ MANUFACTURED: "Hergestellte Ware (Bestand je Herstellungscharge)",
+};
+
+export const SHOP_CATEGORY_LABELS: Record = {
+ HONIG: "Honig",
+ KERZEN: "Kerzen",
+ PROPOLIS: "Propolis",
+ SONSTIGES: "Sonstiges",
+};
diff --git a/ApiCore-Frontend/src/lib/products/types.ts b/ApiCore-Frontend/src/lib/products/types.ts
index a4503eb..a683f38 100644
--- a/ApiCore-Frontend/src/lib/products/types.ts
+++ b/ApiCore-Frontend/src/lib/products/types.ts
@@ -1,5 +1,10 @@
export type ProductStatus = "ACTIVE" | "DISCONTINUED";
+/** Honey: stock per bottling. Everything else: stock per production batch. */
+export type ProductKind = "BOTTLED_HONEY" | "MANUFACTURED";
+
+export type ShopCategory = "HONIG" | "KERZEN" | "PROPOLIS" | "SONSTIGES";
+
export interface Product {
id: string;
name: string;
@@ -9,6 +14,8 @@ export interface Product {
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
+ kind: ProductKind;
+ shopCategory: ShopCategory;
createdAt: string;
updatedAt: string;
}
@@ -20,6 +27,8 @@ export interface CreateProductInput {
packagingSize?: number;
weight?: number;
salesUnit?: string;
+ kind?: ProductKind;
+ shopCategory?: ShopCategory;
}
export interface UpdateProductInput extends Partial {
diff --git a/ApiCore-Frontend/src/lib/stock/api.ts b/ApiCore-Frontend/src/lib/stock/api.ts
new file mode 100644
index 0000000..a19edd4
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/stock/api.ts
@@ -0,0 +1,33 @@
+import { apiFetch } from "@/lib/auth/api-client";
+import {
+ BatchStock,
+ CorrectInput,
+ StockBatchPath,
+ WithdrawInput,
+} from "./types";
+
+export function fetchStock(path: StockBatchPath, id: string): Promise {
+ return apiFetch(`/${path}/${id}/stock`);
+}
+
+export function withdrawStock(
+ path: StockBatchPath,
+ id: string,
+ input: WithdrawInput,
+): Promise {
+ return apiFetch(`/${path}/${id}/withdrawals`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function correctStock(
+ path: StockBatchPath,
+ id: string,
+ input: CorrectInput,
+): Promise {
+ return apiFetch(`/${path}/${id}/corrections`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
diff --git a/ApiCore-Frontend/src/lib/stock/labels.ts b/ApiCore-Frontend/src/lib/stock/labels.ts
new file mode 100644
index 0000000..df06967
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/stock/labels.ts
@@ -0,0 +1,16 @@
+import { MovementType, WithdrawalReason } from "./types";
+
+export const WITHDRAWAL_REASON_LABELS: Record = {
+ HOFLADEN: "Hofladen",
+ EIGENBEDARF: "Eigenbedarf / Geschenk",
+ BRUCH: "Bruch / Verlust",
+ PROBE: "Probe / Muster",
+ SONSTIGES: "Sonstiges",
+};
+
+export const MOVEMENT_TYPE_LABELS: Record = {
+ SHOP_SALE: "Webshop-Verkauf",
+ SHOP_RETURN: "Webshop-Rückbuchung",
+ WITHDRAWAL: "Entnahme",
+ CORRECTION: "Inventur",
+};
diff --git a/ApiCore-Frontend/src/lib/stock/types.ts b/ApiCore-Frontend/src/lib/stock/types.ts
new file mode 100644
index 0000000..028fe8d
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/stock/types.ts
@@ -0,0 +1,55 @@
+/** Collection path of a batch whose stock is tracked in the movement ledger. */
+export type StockBatchPath = "bottling-batches" | "production-batches";
+
+export type WithdrawalReason =
+ | "HOFLADEN"
+ | "EIGENBEDARF"
+ | "BRUCH"
+ | "PROBE"
+ | "SONSTIGES";
+
+export type MovementType =
+ | "SHOP_SALE"
+ | "SHOP_RETURN"
+ | "WITHDRAWAL"
+ | "CORRECTION";
+
+export interface StockSummary {
+ /** Bottled or produced. */
+ initial: number;
+ /** Net shop sales (sales minus shop returns). */
+ soldInShop: number;
+ withdrawn: number;
+ /** Net inventory corrections. */
+ corrected: number;
+ remaining: number;
+}
+
+export interface StockMovement {
+ id: string;
+ type: MovementType;
+ /** Signed: negative takes stock out. */
+ quantity: number;
+ reason: WithdrawalReason | null;
+ notes: string | null;
+ apiKeyId: string | null;
+ createdAt: string;
+ user: { id: string; name: string } | null;
+ shopRequest: { externalId: string; kind: "SALE" | "RETURN" } | null;
+}
+
+export interface BatchStock {
+ summary: StockSummary;
+ movements: StockMovement[];
+}
+
+export interface WithdrawInput {
+ quantity: number;
+ reason: WithdrawalReason;
+ notes?: string;
+}
+
+export interface CorrectInput {
+ countedQuantity: number;
+ notes: string;
+}