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