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:
1 parent
f57db97578
commit
41c2af7284
7 files changed
+614
-9
No files matched your search
+172
@@ -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 „Inventur korrigieren“ 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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Factory } from "lucide-react";
|
import { Factory } from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||||
import { StockCard } from "@/components/stock/stock-card";
|
import { StockCard } from "@/components/stock/stock-card";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
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() {
|
export default function ProductionBatchDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -31,11 +39,18 @@ export default function ProductionBatchDetailPage() {
|
|||||||
toast.success("Charge gelöscht");
|
toast.success("Charge gelöscht");
|
||||||
router.push("/production-batches");
|
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) {
|
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 (
|
return (
|
||||||
@@ -50,6 +65,10 @@ export default function ProductionBatchDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
|
||||||
<CardTitle>Angaben</CardTitle>
|
<CardTitle>Angaben</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{hasPermission("production-batch:update") ? (
|
||||||
|
<EditProductionBatchDialog batch={batch} />
|
||||||
|
) : null}
|
||||||
{hasPermission("production-batch:delete") ? (
|
{hasPermission("production-batch:delete") ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
triggerLabel="Löschen"
|
triggerLabel="Löschen"
|
||||||
@@ -62,14 +81,27 @@ export default function ProductionBatchDetailPage() {
|
|||||||
isPending={deletion.isPending}
|
isPending={deletion.isPending}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2 text-sm">
|
<CardContent className="flex flex-col gap-2 text-sm">
|
||||||
<p>Artikel: {batch.product.name}</p>
|
<p>
|
||||||
<p>Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}</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 ? (
|
{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}
|
) : null}
|
||||||
{batch.notes ? <p className="whitespace-pre-line">Notiz: {batch.notes}</p> : null}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<StockCard
|
<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]}
|
{PRODUCT_STATUS_LABELS[product.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
subtitle={product.kind === "MANUFACTURED" ? "Hergestellte Ware" : "Honig"}
|
||||||
fields={[
|
fields={[
|
||||||
{ label: "SKU", value: product.sku ?? "—" },
|
{ label: "SKU", value: product.sku ?? "—" },
|
||||||
{ label: "Einheit", value: product.salesUnit ?? "—" },
|
{ 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>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CreateProductionBatchInput,
|
CreateProductionBatchInput,
|
||||||
ProductionBatch,
|
ProductionBatch,
|
||||||
ProductionBatchFilter,
|
ProductionBatchFilter,
|
||||||
|
UpdateProductionBatchInput,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export function fetchProductionBatches(
|
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 }> {
|
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
|
||||||
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
|
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export interface CreateProductionBatchInput {
|
|||||||
notes?: string;
|
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 {
|
export interface ProductionBatchFilter {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user