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
+626
-21
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";
|
||||
|
||||
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,26 +65,43 @@ export default function ProductionBatchDetailPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
{hasPermission("production-batch:delete") ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title="Charge löschen"
|
||||
description={`Charge ${batch.lotNumber} wird entfernt. Das geht nur, solange noch nichts verkauft oder entnommen wurde - danach bleibt die Charge als Historie erhalten.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={() => deletion.mutate()}
|
||||
isPending={deletion.isPending}
|
||||
/>
|
||||
) : null}
|
||||
<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"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title="Charge löschen"
|
||||
description={`Charge ${batch.lotNumber} wird entfernt. Das geht nur, solange noch nichts verkauft oder entnommen wurde - danach bleibt die Charge als Historie erhalten.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={() => deletion.mutate()}
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user