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

@@ -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>
);
}