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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user