feat(ui): article stock overview and production batch editing

- New article page /products/[id]: remaining stock summed over all
  production batches (or bottlings for honey), each batch linked to its
  own page for withdrawals and counts; "Bestand ansehen" in the list.
- Production batches can be corrected (lot number, date, quantity,
  best-before date, note) via PATCH; the article stays fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 21:28:15 +02:00
1 parent f57db97578
commit 41c2af7284
7 files changed
+614 -9

No files matched your search

@@ -0,0 +1,172 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProductionBatch } from "@/lib/production-batches/api";
import { ProductionBatch } from "@/lib/production-batches/types";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Corrects a production batch entered with a typo. The article stays fixed:
* moving a batch with sales to another article would rewrite where those
* sales came from. A changed quantity changes the remaining stock - in the
* shop as well.
*/
export function EditProductionBatchDialog({
batch,
}: {
batch: ProductionBatch;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Herstellungscharge bearbeiten</DialogTitle>
</DialogHeader>
{open ? (
<EditForm batch={batch} onSaved={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
function EditForm({
batch,
onSaved,
}: {
batch: ProductionBatch;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [lotNumber, setLotNumber] = useState(batch.lotNumber);
const [date, setDate] = useState(batch.date.slice(0, 10));
const [quantity, setQuantity] = useState(String(batch.quantity));
const [bestBeforeDate, setBestBeforeDate] = useState(
batch.bestBeforeDate?.slice(0, 10) ?? "",
);
const [notes, setNotes] = useState(batch.notes ?? "");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateProductionBatch(batch.id, {
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: null,
notes: notes.trim(),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
void queryClient.invalidateQueries({
queryKey: ["stock", "production-batches", batch.id],
});
toast.success("Charge gespeichert");
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Charge konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-lot">Chargennummer</Label>
<Input
id="pb-edit-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-date">Herstelldatum</Label>
<Input
id="pb-edit-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-quantity">Hergestellte Stückzahl</Label>
<Input
id="pb-edit-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">
Ändert den Rest (auch im Webshop). Fehlende oder gefundene Stück bitte
über &bdquo;Inventur korrigieren&ldquo; buchen, nicht hier.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-best-before">
Mindesthaltbar bis (optional)
</Label>
<Input
id="pb-edit-best-before"
type="date"
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-notes">Notiz (intern)</Label>
<Textarea
id="pb-edit-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 ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
@@ -1,17 +1,25 @@
"use client";
import Link from "next/link";
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 {
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";
import { EditProductionBatchDialog } from "./edit-production-batch-dialog";
const BACK = { backHref: "/production-batches", backLabel: "Alle Herstellungschargen" };
const BACK = {
backHref: "/production-batches",
backLabel: "Alle Herstellungschargen",
};
export default function ProductionBatchDetailPage() {
const params = useParams<{ id: string }>();
@@ -31,11 +39,18 @@ export default function ProductionBatchDetailPage() {
toast.success("Charge gelöscht");
router.push("/production-batches");
},
onError: (error: Error) => toast.error(error.message || "Charge konnte nicht gelöscht werden"),
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 (
<DetailQueryFallback
query={batchQuery}
what="Daten der Charge"
{...BACK}
/>
);
}
return (
@@ -50,6 +65,10 @@ export default function ProductionBatchDetailPage() {
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
<div className="flex flex-wrap items-center gap-2">
{hasPermission("production-batch:update") ? (
<EditProductionBatchDialog batch={batch} />
) : null}
{hasPermission("production-batch:delete") ? (
<ConfirmDialog
triggerLabel="Löschen"
@@ -62,14 +81,27 @@ export default function ProductionBatchDetailPage() {
isPending={deletion.isPending}
/>
) : null}
</div>
</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>
<p>
Artikel:{" "}
<Link className="underline" href={`/products/${batch.productId}`}>
{batch.product.name}
</Link>
</p>
<p>
Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}
</p>
{batch.bestBeforeDate ? (
<p>Mindesthaltbar bis: {new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}</p>
<p>
Mindesthaltbar bis:{" "}
{new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}
</p>
) : null}
{batch.notes ? (
<p className="whitespace-pre-line">Notiz: {batch.notes}</p>
) : null}
{batch.notes ? <p className="whitespace-pre-line">Notiz: {batch.notes}</p> : null}
</CardContent>
</Card>
<StockCard
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ProductDetailPage from "./page";
import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "candle" }),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => false }),
}));
vi.mock("@/lib/products/api", () => ({
fetchProduct: vi.fn(),
updateProduct: vi.fn(),
}));
vi.mock("@/lib/production-batches/api", () => ({
fetchProductionBatches: vi.fn(),
}));
vi.mock("@/lib/bottling/api", () => ({
fetchBottlingBatches: vi.fn(async () => []),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ProductDetailPage />
</QueryClientProvider>,
);
}
describe("ProductDetailPage", () => {
beforeEach(() => {
vi.mocked(fetchProduct).mockResolvedValue({
id: "candle",
name: "Bienenwachskerze klein",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "MANUFACTURED",
shopCategory: "KERZEN",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(fetchProductionBatches).mockResolvedValue([
{
id: "b1",
productId: "candle",
lotNumber: "K-1",
date: "2026-02-01T00:00:00.000Z",
quantity: 3,
bestBeforeDate: null,
notes: null,
createdAt: "2026-02-01T00:00:00.000Z",
remainingCount: 1,
product: { id: "candle", name: "Bienenwachskerze klein" },
},
{
id: "b2",
productId: "candle",
lotNumber: "K-2",
date: "2026-05-01T00:00:00.000Z",
quantity: 5,
bestBeforeDate: null,
notes: null,
createdAt: "2026-05-01T00:00:00.000Z",
remainingCount: 5,
product: { id: "candle", name: "Bienenwachskerze klein" },
},
]);
});
it("sums the remaining stock over all production batches", async () => {
renderPage();
expect(await screen.findByTestId("total-remaining")).toHaveTextContent(
"6 Stück",
);
expect(screen.getByText("K-1")).toBeInTheDocument();
expect(screen.getByText("K-2")).toBeInTheDocument();
});
it("links every batch to its own page for withdrawals", async () => {
renderPage();
const link = (await screen.findByText("K-1")).closest("a");
expect(link).toHaveAttribute("href", "/production-batches/b1");
});
});
@@ -0,0 +1,273 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api";
import { fetchBottlingBatches } from "@/lib/bottling/api";
import {
PRODUCT_KIND_LABELS,
PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels";
import { Product } from "@/lib/products/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { useAuth } from "@/lib/auth/auth-context";
import { EditProductDialog } from "../edit-product-dialog";
const BACK = { backHref: "/products", backLabel: "Alle Produkte" };
/** One stock line: a bottling (honey) or a production batch. */
interface StockRow {
id: string;
href: string;
lotNumber: string;
date: string;
initial: number;
remaining: number;
}
export default function ProductDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const productQuery = useQuery({
queryKey: ["products", params.id],
queryFn: () => fetchProduct(params.id),
});
const product = productQuery.data;
if (productQuery.isLoading || productQuery.isError || !product) {
return (
<DetailQueryFallback
query={productQuery}
what="Daten des Produkts"
{...BACK}
/>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={ShoppingBag}
title={product.name}
subtitle={PRODUCT_KIND_LABELS[product.kind]}
{...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("products:update") ? (
<EditProductDialog product={product} />
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>
Status:{" "}
<Badge
variant={product.status === "ACTIVE" ? "default" : "secondary"}
>
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
</p>
<p>
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
</p>
{product.sku ? <p>SKU: {product.sku}</p> : null}
{product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p>
) : null}
{product.description ? (
<p className="whitespace-pre-line">{product.description}</p>
) : null}
</CardContent>
</Card>
{product.kind === "MANUFACTURED" ? (
<ManufacturedStock product={product} />
) : (
<BottledStock product={product} />
)}
</div>
</main>
);
}
function ManufacturedStock({ product }: { product: Product }) {
const { hasPermission } = useAuth();
const batchesQuery = useQuery({
queryKey: ["production-batches", { productId: product.id }],
queryFn: () => fetchProductionBatches({ productId: product.id }),
});
const rows: StockRow[] = (batchesQuery.data ?? []).map((batch) => ({
id: batch.id,
href: `/production-batches/${batch.id}`,
lotNumber: batch.lotNumber,
date: batch.date,
initial: batch.quantity,
remaining: batch.remainingCount,
}));
return (
<StockOverview
title="Bestand (alle Herstellungschargen)"
initialLabel="Hergestellt"
unit="Stück"
rows={rows}
isLoading={batchesQuery.isLoading}
isError={batchesQuery.isError}
empty="Noch keine Herstellungscharge - der Bestand ist 0."
action={
hasPermission("production-batch:create") ? (
<Button
size="sm"
nativeButton={false}
render={<Link href="/production-batches/new" />}
>
+ Neue Charge
</Button>
) : null
}
/>
);
}
function BottledStock({ product }: { product: Product }) {
const bottlingQuery = useQuery({
queryKey: ["bottling-batches"],
queryFn: () => fetchBottlingBatches(),
});
const rows: StockRow[] = (bottlingQuery.data ?? [])
.filter((bottling) => bottling.productId === product.id)
.map((bottling) => ({
id: bottling.id,
href: `/bottling/${bottling.id}`,
lotNumber: bottling.lotNumber,
date: bottling.date,
initial: bottling.jarCount,
remaining: bottling.remainingCount,
}));
return (
<StockOverview
title="Bestand (alle Abfüllungen)"
initialLabel="Abgefüllt"
unit="Gläser"
rows={rows}
isLoading={bottlingQuery.isLoading}
isError={bottlingQuery.isError}
empty="Noch keine Abfüllung mit diesem Produkt."
action={null}
/>
);
}
/**
* Summe über alle Chargen eines Artikels - genau die Zahl, die der Webshop
* als Bestand fuehrt (abzueglich offener Bestellungen dort). Jede Zeile
* fuehrt zur Charge, wo entnommen und inventiert wird.
*/
function StockOverview({
title,
initialLabel,
unit,
rows,
isLoading,
isError,
empty,
action,
}: {
title: string;
initialLabel: string;
unit: string;
rows: StockRow[];
isLoading: boolean;
isError: boolean;
empty: string;
action: React.ReactNode;
}) {
const totalInitial = rows.reduce((sum, row) => sum + row.initial, 0);
const totalRemaining = rows.reduce((sum, row) => sum + row.remaining, 0);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>{title}</CardTitle>
{action}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isError ? (
<p role="alert" className="text-sm text-destructive">
Bestand konnte nicht geladen werden.
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : (
<>
<dl className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<dt className="text-xs text-muted-foreground">
{initialLabel}
</dt>
<dd className="text-xl font-semibold tabular-nums">
{totalInitial}
</dd>
</div>
<div
className={
totalRemaining < 0
? "rounded-lg border border-destructive bg-destructive/10 p-3"
: "rounded-lg border border-primary bg-primary/5 p-3"
}
>
<dt className="text-xs text-muted-foreground">Rest gesamt</dt>
<dd
className="text-xl font-semibold tabular-nums"
data-testid="total-remaining"
>
{totalRemaining} {unit}
</dd>
</div>
</dl>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{empty}</p>
) : (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{rows.map((row) => (
<li key={row.id}>
<Link
href={row.href}
className="flex items-baseline justify-between gap-2 p-3 hover:bg-muted"
>
<span className="flex flex-col">
<span className="font-mono font-medium">
{row.lotNumber}
</span>
<span className="text-xs text-muted-foreground">
{new Date(row.date).toLocaleDateString("de-DE")} ·{" "}
{initialLabel} {row.initial}
</span>
</span>
<span
className={
row.remaining < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums"
}
>
Rest {row.remaining}
{row.remaining < 0 ? " (prüfen)" : ""}
</span>
</Link>
</li>
))}
</ul>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -92,11 +92,24 @@ function ProductGrid({ products }: { products: Product[] }) {
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
}
subtitle={product.kind === "MANUFACTURED" ? "Hergestellte Ware" : "Honig"}
fields={[
{ label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" },
]}
actions={<EditProductDialog product={product} />}
actions={
<>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href={`/products/${product.id}`} />}
>
Bestand ansehen
</Button>
<EditProductDialog product={product} />
</>
}
/>
))}
</div>
@@ -3,6 +3,7 @@ import {
CreateProductionBatchInput,
ProductionBatch,
ProductionBatchFilter,
UpdateProductionBatchInput,
} from "./types";
export function fetchProductionBatches(
@@ -29,6 +30,16 @@ export function createProductionBatch(
});
}
export function updateProductionBatch(
id: string,
input: UpdateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
method: "DELETE",
@@ -20,6 +20,15 @@ export interface CreateProductionBatchInput {
notes?: string;
}
export interface UpdateProductionBatchInput {
lotNumber?: string;
date?: string;
quantity?: number;
/** `null` removes the date, `undefined` leaves it untouched. */
bestBeforeDate?: string | null;
notes?: string;
}
export interface ProductionBatchFilter {
productId?: string;
}