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 { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card"; import { PrintLabelsCard } from "./print-labels-card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" }; const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" };
@@ -78,6 +79,13 @@ export default function BottlingBatchDetailPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<StockCard
path="bottling-batches"
id={bottlingBatch.id}
initialLabel="Abgefüllt"
unit="Gläser"
canBook={hasPermission("bottling:withdraw")}
/>
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} /> <PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
</div> </div>
</main> </main>
@@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() {
<SelectValue placeholder="Produkt wählen" /> <SelectValue placeholder="Produkt wählen" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{products?.map((product) => ( {products
?.filter((product) => product.kind !== "MANUFACTURED")
.map((product) => (
<SelectItem key={product.id} value={product.id}> <SelectItem key={product.id} value={product.id}>
{product.name} {product.name}
</SelectItem> </SelectItem>
@@ -63,6 +63,13 @@ export default function BottlingBatchesPage() {
label: "Menge", label: "Menge",
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`, 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) }, { 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api"; import { updateProduct } from "@/lib/products/api";
import { Product, ProductStatus } from "@/lib/products/types"; import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels"; import {
PRODUCT_KIND_LABELS,
PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -37,6 +41,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [weight, setWeight] = useState(product.weight?.toString() ?? ""); const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? ""); const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const [status, setStatus] = useState<ProductStatus>(product.status); const [status, setStatus] = useState<ProductStatus>(product.status);
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -48,6 +53,7 @@ export function EditProductDialog({ product }: { product: Product }) {
weight: weight ? Number(weight) : undefined, weight: weight ? Number(weight) : undefined,
salesUnit: salesUnit || undefined, salesUnit: salesUnit || undefined,
status, status,
shopCategory,
}), }),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["products"] }); void queryClient.invalidateQueries({ queryKey: ["products"] });
@@ -145,6 +151,29 @@ export function EditProductDialog({ product }: { product: Product }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label> <Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
<Input <Input
@@ -59,6 +59,8 @@ describe("NewProductPage", () => {
packagingSize: 500, packagingSize: 500,
weight: undefined, weight: undefined,
salesUnit: undefined, salesUnit: undefined,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
}); });
expect(push).toHaveBeenCalledWith("/products"); expect(push).toHaveBeenCalledWith("/products");
}); });
@@ -17,6 +17,15 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit"; import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner"; 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 { import {
ProductFormErrors, ProductFormErrors,
ProductFormValues, ProductFormValues,
@@ -121,6 +130,51 @@ export default function NewProductPage() {
/> />
)} )}
</Field> </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"> <Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <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. */ /** Everything the form holds, as plain values that survive a draft save. */
export interface ProductFormValues { export interface ProductFormValues {
@@ -8,6 +12,8 @@ export interface ProductFormValues {
weight: string; weight: string;
sku: string; sku: string;
description: string; description: string;
kind: ProductKind;
shopCategory: ShopCategory;
} }
export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>; export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>;
@@ -20,6 +26,8 @@ export function initialProductValues(): ProductFormValues {
weight: "", weight: "",
sku: "", sku: "",
description: "", description: "",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
}; };
} }
@@ -43,5 +51,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
packagingSize: numberOrUndefined(values.packagingSize), packagingSize: numberOrUndefined(values.packagingSize),
weight: numberOrUndefined(values.weight), weight: numberOrUndefined(values.weight),
salesUnit: textOrUndefined(values.salesUnit), salesUnit: textOrUndefined(values.salesUnit),
kind: values.kind,
shopCategory: values.shopCategory,
}; };
} }
@@ -8,6 +8,7 @@ import {
Crown, Crown,
Database, Database,
Droplet, Droplet,
Factory,
FileClock, FileClock,
FlaskConical, FlaskConical,
Hexagon, Hexagon,
@@ -81,6 +82,12 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical }, { label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet }, { label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package }, { 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: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse }, { label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench }, { label: "Inventar", href: "/equipment", icon: Wrench },
@@ -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(
<QueryClientProvider client={client}>
<StockCard
path="bottling-batches"
id="b1"
initialLabel="Abgefüllt"
unit="Gläser"
canBook={canBook}
/>
</QueryClientProvider>,
);
}
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();
});
});
@@ -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 (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Bestand</CardTitle>
{canBook && stock ? (
<div className="flex flex-wrap items-center gap-2">
<WithdrawDialog path={path} id={id} unit={unit} remaining={stock.summary.remaining} />
<CorrectDialog path={path} id={id} unit={unit} remaining={stock.summary.remaining} />
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{stockQuery.isError ? (
<p role="alert" className="text-sm text-destructive">
Bestand konnte nicht geladen werden.
</p>
) : !stock ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : (
<>
<SummaryGrid summary={stock.summary} initialLabel={initialLabel} />
<MovementList movements={stock.movements} />
</>
)}
</CardContent>
</Card>
);
}
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 (
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="rounded-lg border p-3">
<dt className="text-xs text-muted-foreground">{item.label}</dt>
<dd className="text-xl font-semibold tabular-nums">{item.value}</dd>
</div>
))}
<div
className={
needsReview
? "rounded-lg border border-destructive bg-destructive/10 p-3"
: "rounded-lg border border-primary bg-primary/5 p-3"
}
>
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
Rest
{needsReview ? <Badge variant="destructive">Prüfen</Badge> : null}
</dt>
<dd className="text-xl font-semibold tabular-nums">{summary.remaining}</dd>
{summary.corrected !== 0 ? (
<p className="text-xs text-muted-foreground">
inkl. Inventur {summary.corrected > 0 ? "+" : ""}
{summary.corrected}
</p>
) : null}
</div>
</dl>
);
}
function MovementList({ movements }: { movements: StockMovement[] }) {
if (movements.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Noch keine Bewegungen - der Rest entspricht der Ausgangsmenge.
</p>
);
}
return (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{movements.map((movement) => (
<li key={movement.id} className="flex flex-wrap items-baseline justify-between gap-2 p-3">
<div className="flex flex-col">
<span className="font-medium">
{MOVEMENT_TYPE_LABELS[movement.type]}
{movement.reason ? ` · ${WITHDRAWAL_REASON_LABELS[movement.reason]}` : ""}
</span>
<span className="text-xs text-muted-foreground">
{new Date(movement.createdAt).toLocaleString("de-DE")} ·{" "}
{movement.user?.name ?? (movement.shopRequest ? "Webshop" : "System")}
</span>
{movement.notes ? (
<span className="text-xs text-muted-foreground">{movement.notes}</span>
) : null}
</div>
<span
className={
movement.quantity < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary"
}
>
{movement.quantity > 0 ? "+" : ""}
{movement.quantity}
</span>
</li>
))}
</ul>
);
}
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<WithdrawalReason>("HOFLADEN");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="sm" disabled={remaining <= 0}>
<PackageMinus className="size-3.5" />
Entnehmen
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Entnehmen</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Noch da: {remaining} {unit}. Die Entnahme mindert auch den Bestand im Webshop.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
<Input
id="withdraw-quantity"
type="number"
inputMode="numeric"
min={1}
max={remaining}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-reason">Grund</Label>
<Select
value={reason}
onValueChange={(value) => setReason((value ?? reason) as WithdrawalReason)}
>
<SelectTrigger id="withdraw-reason" className="w-full">
<SelectValue>{WITHDRAWAL_REASON_LABELS[reason]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]).map((option) => (
<SelectItem key={option} value={option}>
{WITHDRAWAL_REASON_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-notes">Notiz (optional)</Label>
<Textarea
id="withdraw-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" disabled={mutation.isPending}>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
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<string | null>(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<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setCounted(String(Math.max(remaining, 0)));
}}
>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<ClipboardCheck className="size-3.5" />
Inventur korrigieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Inventur korrigieren</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Gebucht sind {remaining} {unit}. Trag ein, wie viele du gezählt hast - gebucht wird die
Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-counted">Gezählt ({unit})</Label>
<Input
id="correct-counted"
type="number"
inputMode="numeric"
min={0}
step={1}
required
value={counted}
onChange={(event) => setCounted(event.target.value)}
/>
{difference !== 0 ? (
<p className="text-xs text-muted-foreground">
Differenz: {difference > 0 ? "+" : ""}
{difference} {unit}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-notes">Warum weicht der Bestand ab?</Label>
<Textarea
id="correct-notes"
required
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending || difference === 0}>
{mutation.isPending ? "Bucht…" : "Inventur buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -9,6 +9,8 @@ export interface BottlingBatch {
bestBeforeDate: string; bestBeforeDate: string;
lotNumber: string; lotNumber: string;
createdAt: string; createdAt: string;
/** jarCount plus all stock movements (shop sales, withdrawals, ...). */
remainingCount: number;
honeyBatch: { id: string; batchNumber: string }; honeyBatch: { id: string; batchNumber: string };
product: { id: string; name: string } | null; product: { id: string; name: string } | null;
} }
@@ -0,0 +1,36 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateProductionBatchInput,
ProductionBatch,
ProductionBatchFilter,
} from "./types";
export function fetchProductionBatches(
filter: ProductionBatchFilter = {},
): Promise<ProductionBatch[]> {
const params = new URLSearchParams();
if (filter.productId) params.set("productId", filter.productId);
const query = params.toString();
return apiFetch<ProductionBatch[]>(
`/production-batches${query ? `?${query}` : ""}`,
);
}
export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`);
}
export function createProductionBatch(
input: CreateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>("/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",
});
}
@@ -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;
}
+13 -1
View File
@@ -1,6 +1,18 @@
import { ProductStatus } from "./types"; import { ProductKind, ProductStatus, ShopCategory } from "./types";
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = { export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
ACTIVE: "Aktiv", ACTIVE: "Aktiv",
DISCONTINUED: "Eingestellt", DISCONTINUED: "Eingestellt",
}; };
export const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
BOTTLED_HONEY: "Honig (Bestand je Abfüllung)",
MANUFACTURED: "Hergestellte Ware (Bestand je Herstellungscharge)",
};
export const SHOP_CATEGORY_LABELS: Record<ShopCategory, string> = {
HONIG: "Honig",
KERZEN: "Kerzen",
PROPOLIS: "Propolis",
SONSTIGES: "Sonstiges",
};
@@ -1,5 +1,10 @@
export type ProductStatus = "ACTIVE" | "DISCONTINUED"; 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 { export interface Product {
id: string; id: string;
name: string; name: string;
@@ -9,6 +14,8 @@ export interface Product {
weight: number | null; weight: number | null;
salesUnit: string | null; salesUnit: string | null;
status: ProductStatus; status: ProductStatus;
kind: ProductKind;
shopCategory: ShopCategory;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -20,6 +27,8 @@ export interface CreateProductInput {
packagingSize?: number; packagingSize?: number;
weight?: number; weight?: number;
salesUnit?: string; salesUnit?: string;
kind?: ProductKind;
shopCategory?: ShopCategory;
} }
export interface UpdateProductInput extends Partial<CreateProductInput> { export interface UpdateProductInput extends Partial<CreateProductInput> {
+33
View File
@@ -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<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/stock`);
}
export function withdrawStock(
path: StockBatchPath,
id: string,
input: WithdrawInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/withdrawals`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function correctStock(
path: StockBatchPath,
id: string,
input: CorrectInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { MovementType, WithdrawalReason } from "./types";
export const WITHDRAWAL_REASON_LABELS: Record<WithdrawalReason, string> = {
HOFLADEN: "Hofladen",
EIGENBEDARF: "Eigenbedarf / Geschenk",
BRUCH: "Bruch / Verlust",
PROBE: "Probe / Muster",
SONSTIGES: "Sonstiges",
};
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
SHOP_SALE: "Webshop-Verkauf",
SHOP_RETURN: "Webshop-Rückbuchung",
WITHDRAWAL: "Entnahme",
CORRECTION: "Inventur",
};
+55
View File
@@ -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;
}