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
@@ -12,6 +12,7 @@ import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||
import { BottlingDeleteButton } from "./bottling-delete-button";
|
||||
import { PrintLabelsCard } from "./print-labels-card";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { StockCard } from "@/components/stock/stock-card";
|
||||
|
||||
const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" };
|
||||
|
||||
@@ -78,6 +79,13 @@ export default function BottlingBatchDetailPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StockCard
|
||||
path="bottling-batches"
|
||||
id={bottlingBatch.id}
|
||||
initialLabel="Abgefüllt"
|
||||
unit="Gläser"
|
||||
canBook={hasPermission("bottling:withdraw")}
|
||||
/>
|
||||
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() {
|
||||
<SelectValue placeholder="Produkt wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products?.map((product) => (
|
||||
{products
|
||||
?.filter((product) => product.kind !== "MANUFACTURED")
|
||||
.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -63,6 +63,13 @@ export default function BottlingBatchesPage() {
|
||||
label: "Menge",
|
||||
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
|
||||
},
|
||||
{
|
||||
label: "Rest",
|
||||
value:
|
||||
bottlingBatch.remainingCount < 0
|
||||
? `${bottlingBatch.remainingCount} (prüfen)`
|
||||
: `${bottlingBatch.remainingCount} Gläser`,
|
||||
},
|
||||
{ label: "Datum", value: formatDate(bottlingBatch.date) },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
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 { 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";
|
||||
|
||||
const BACK = { backHref: "/production-batches", backLabel: "Alle Herstellungschargen" };
|
||||
|
||||
export default function ProductionBatchDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
const batchQuery = useQuery({
|
||||
queryKey: ["production-batches", params.id],
|
||||
queryFn: () => fetchProductionBatch(params.id),
|
||||
});
|
||||
const batch = batchQuery.data;
|
||||
|
||||
const deletion = useMutation({
|
||||
mutationFn: () => deleteProductionBatch(params.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
|
||||
toast.success("Charge gelöscht");
|
||||
router.push("/production-batches");
|
||||
},
|
||||
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 (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Factory}
|
||||
title={`Charge ${batch.lotNumber}`}
|
||||
subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`}
|
||||
{...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("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}
|
||||
</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>
|
||||
{batch.bestBeforeDate ? (
|
||||
<p>Mindesthaltbar bis: {new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}</p>
|
||||
) : null}
|
||||
{batch.notes ? <p className="whitespace-pre-line">Notiz: {batch.notes}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StockCard
|
||||
path="production-batches"
|
||||
id={batch.id}
|
||||
initialLabel="Hergestellt"
|
||||
unit="Stück"
|
||||
canBook={hasPermission("production-batch:withdraw")}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Factory } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createProductionBatch } from "@/lib/production-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function NewProductionBatchPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [productId, setProductId] = useState("");
|
||||
const [lotNumber, setLotNumber] = useState("");
|
||||
const [date, setDate] = useState(today());
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||
});
|
||||
const manufactured = (products ?? []).filter((product) => product.kind === "MANUFACTURED");
|
||||
const selected = manufactured.find((product) => product.id === productId);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createProductionBatch({
|
||||
productId,
|
||||
lotNumber: lotNumber.trim(),
|
||||
date: new Date(date).toISOString(),
|
||||
quantity: Number(quantity),
|
||||
bestBeforeDate: bestBeforeDate ? new Date(bestBeforeDate).toISOString() : undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (batch) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
|
||||
toast.success(`Charge ${batch.lotNumber} angelegt`);
|
||||
router.push(`/production-batches/${batch.id}`);
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message = mutationError.message || "Charge konnte nicht angelegt werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!productId) {
|
||||
setError("Bitte einen Artikel wählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Factory}
|
||||
title="Neue Herstellungscharge"
|
||||
description="Was hast du hergestellt, und wie viele Stück?"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form className="flex max-w-xl flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-product">Artikel</Label>
|
||||
<Select value={productId} onValueChange={(value) => setProductId(value ?? "")}>
|
||||
<SelectTrigger id="pb-product" className="w-full">
|
||||
<SelectValue placeholder="Artikel wählen">{selected?.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manufactured.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{products && manufactured.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Noch kein Artikel der Art „Hergestellte Ware“. Lege ihn unter Produkte an.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-lot">Chargennummer</Label>
|
||||
<Input
|
||||
id="pb-lot"
|
||||
required
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-date">Herstelldatum</Label>
|
||||
<Input
|
||||
id="pb-date"
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-quantity">Hergestellte Stückzahl</Label>
|
||||
<Input
|
||||
id="pb-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
required
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-best-before">Mindesthaltbar bis (optional)</Label>
|
||||
<Input
|
||||
id="pb-best-before"
|
||||
type="date"
|
||||
value={bestBeforeDate}
|
||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pb-notes">Notiz (optional, intern)</Label>
|
||||
<Textarea id="pb-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" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Charge anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Factory } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchProductionBatches } from "@/lib/production-batches/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
export default function ProductionBatchesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("production-batch:create");
|
||||
const batchesQuery = useQuery({
|
||||
queryKey: ["production-batches"],
|
||||
queryFn: () => fetchProductionBatches(),
|
||||
});
|
||||
|
||||
const newButton = canCreate ? (
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/production-batches/new" />}>
|
||||
+ Neue Herstellungscharge
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Factory}
|
||||
title="Herstellungschargen"
|
||||
description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest."
|
||||
actions={newButton}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
query={batchesQuery}
|
||||
what="Herstellungschargen"
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Factory}
|
||||
title="Noch keine Herstellungschargen"
|
||||
description="Lege eine Charge an, sobald du Kerzen, Tinkturen oder Salben hergestellt hast. Der Artikel muss die Art „Hergestellte Ware“ haben."
|
||||
action={newButton ?? undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(batches) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{batches.map((batch) => (
|
||||
<EntityCard
|
||||
key={batch.id}
|
||||
href={`/production-batches/${batch.id}`}
|
||||
icon={Factory}
|
||||
title={<span className="font-mono">{batch.lotNumber}</span>}
|
||||
subtitle={batch.product.name}
|
||||
fields={[
|
||||
{ label: "Hergestellt", value: String(batch.quantity) },
|
||||
{
|
||||
label: "Rest",
|
||||
value: batch.remainingCount < 0 ? `${batch.remainingCount} (prüfen)` : String(batch.remainingCount),
|
||||
},
|
||||
{ label: "Datum", value: new Date(batch.date).toLocaleDateString("de-DE") },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateProduct } from "@/lib/products/api";
|
||||
import { Product, ProductStatus } from "@/lib/products/types";
|
||||
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels";
|
||||
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
|
||||
import {
|
||||
PRODUCT_KIND_LABELS,
|
||||
PRODUCT_STATUS_LABELS,
|
||||
SHOP_CATEGORY_LABELS,
|
||||
} from "@/lib/products/labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -37,6 +41,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
|
||||
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
|
||||
const [status, setStatus] = useState<ProductStatus>(product.status);
|
||||
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -48,6 +53,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
salesUnit: salesUnit || undefined,
|
||||
status,
|
||||
shopCategory,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
@@ -145,6 +151,29 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-shop-category">Kategorie im Webshop</Label>
|
||||
<Select
|
||||
value={shopCategory}
|
||||
onValueChange={(value) =>
|
||||
setShopCategory((value ?? shopCategory) as ShopCategory)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="product-edit-shop-category" className="w-full">
|
||||
<SelectValue>{SHOP_CATEGORY_LABELS[shopCategory]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SHOP_CATEGORY_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
|
||||
<Input
|
||||
|
||||
@@ -59,6 +59,8 @@ describe("NewProductPage", () => {
|
||||
packagingSize: 500,
|
||||
weight: undefined,
|
||||
salesUnit: undefined,
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/products");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,15 @@ import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PRODUCT_KIND_LABELS, SHOP_CATEGORY_LABELS } from "@/lib/products/labels";
|
||||
import type { ProductKind, ShopCategory } from "@/lib/products/types";
|
||||
import {
|
||||
ProductFormErrors,
|
||||
ProductFormValues,
|
||||
@@ -121,6 +130,51 @@ export default function NewProductPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Art"
|
||||
hint="Honig wird abgefüllt; Kerzen, Tinkturen und Salben bekommen Herstellungschargen"
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Select
|
||||
value={values.kind}
|
||||
onValueChange={(value) =>
|
||||
set("kind", (value ?? values.kind) as ProductKind)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
|
||||
<SelectValue>{PRODUCT_KIND_LABELS[values.kind]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(PRODUCT_KIND_LABELS) as ProductKind[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{PRODUCT_KIND_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Kategorie im Webshop">
|
||||
{({ id, describedBy }) => (
|
||||
<Select
|
||||
value={values.shopCategory}
|
||||
onValueChange={(value) =>
|
||||
set("shopCategory", (value ?? values.shopCategory) as ShopCategory)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
|
||||
<SelectValue>{SHOP_CATEGORY_LABELS[values.shopCategory]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{SHOP_CATEGORY_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CreateProductInput } from "@/lib/products/types";
|
||||
import type {
|
||||
CreateProductInput,
|
||||
ProductKind,
|
||||
ShopCategory,
|
||||
} from "@/lib/products/types";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ProductFormValues {
|
||||
@@ -8,6 +12,8 @@ export interface ProductFormValues {
|
||||
weight: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
kind: ProductKind;
|
||||
shopCategory: ShopCategory;
|
||||
}
|
||||
|
||||
export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>;
|
||||
@@ -20,6 +26,8 @@ export function initialProductValues(): ProductFormValues {
|
||||
weight: "",
|
||||
sku: "",
|
||||
description: "",
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,5 +51,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
|
||||
packagingSize: numberOrUndefined(values.packagingSize),
|
||||
weight: numberOrUndefined(values.weight),
|
||||
salesUnit: textOrUndefined(values.salesUnit),
|
||||
kind: values.kind,
|
||||
shopCategory: values.shopCategory,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Crown,
|
||||
Database,
|
||||
Droplet,
|
||||
Factory,
|
||||
FileClock,
|
||||
FlaskConical,
|
||||
Hexagon,
|
||||
@@ -81,6 +82,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
|
||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
||||
{
|
||||
label: "Herstellungschargen",
|
||||
href: "/production-batches",
|
||||
icon: Factory,
|
||||
permission: "production-batch:read",
|
||||
},
|
||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||
{ label: "Lager", href: "/storage", icon: Warehouse },
|
||||
{ label: "Inventar", href: "/equipment", icon: Wrench },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface BottlingBatch {
|
||||
bestBeforeDate: string;
|
||||
lotNumber: string;
|
||||
createdAt: string;
|
||||
/** jarCount plus all stock movements (shop sales, withdrawals, ...). */
|
||||
remainingCount: number;
|
||||
honeyBatch: { id: string; batchNumber: string };
|
||||
product: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
CreateProductionBatchInput,
|
||||
ProductionBatch,
|
||||
ProductionBatchFilter,
|
||||
} from "./types";
|
||||
|
||||
export function fetchProductionBatches(
|
||||
filter: ProductionBatchFilter = {},
|
||||
): Promise<ProductionBatch[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.productId) params.set("productId", filter.productId);
|
||||
const query = params.toString();
|
||||
return apiFetch<ProductionBatch[]>(
|
||||
`/production-batches${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
|
||||
return apiFetch<ProductionBatch>(`/production-batches/${id}`);
|
||||
}
|
||||
|
||||
export function createProductionBatch(
|
||||
input: CreateProductionBatchInput,
|
||||
): Promise<ProductionBatch> {
|
||||
return apiFetch<ProductionBatch>("/production-batches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
|
||||
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ProductionBatch {
|
||||
id: string;
|
||||
productId: string;
|
||||
lotNumber: string;
|
||||
date: string;
|
||||
quantity: number;
|
||||
bestBeforeDate: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
remainingCount: number;
|
||||
product: { id: string; name: string };
|
||||
}
|
||||
|
||||
export interface CreateProductionBatchInput {
|
||||
productId: string;
|
||||
lotNumber: string;
|
||||
date: string;
|
||||
quantity: number;
|
||||
bestBeforeDate?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProductionBatchFilter {
|
||||
productId?: string;
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import { ProductStatus } from "./types";
|
||||
import { ProductKind, ProductStatus, ShopCategory } from "./types";
|
||||
|
||||
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
|
||||
ACTIVE: "Aktiv",
|
||||
DISCONTINUED: "Eingestellt",
|
||||
};
|
||||
|
||||
export const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
|
||||
BOTTLED_HONEY: "Honig (Bestand je Abfüllung)",
|
||||
MANUFACTURED: "Hergestellte Ware (Bestand je Herstellungscharge)",
|
||||
};
|
||||
|
||||
export const SHOP_CATEGORY_LABELS: Record<ShopCategory, string> = {
|
||||
HONIG: "Honig",
|
||||
KERZEN: "Kerzen",
|
||||
PROPOLIS: "Propolis",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
export type ProductStatus = "ACTIVE" | "DISCONTINUED";
|
||||
|
||||
/** Honey: stock per bottling. Everything else: stock per production batch. */
|
||||
export type ProductKind = "BOTTLED_HONEY" | "MANUFACTURED";
|
||||
|
||||
export type ShopCategory = "HONIG" | "KERZEN" | "PROPOLIS" | "SONSTIGES";
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,6 +14,8 @@ export interface Product {
|
||||
weight: number | null;
|
||||
salesUnit: string | null;
|
||||
status: ProductStatus;
|
||||
kind: ProductKind;
|
||||
shopCategory: ShopCategory;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -20,6 +27,8 @@ export interface CreateProductInput {
|
||||
packagingSize?: number;
|
||||
weight?: number;
|
||||
salesUnit?: string;
|
||||
kind?: ProductKind;
|
||||
shopCategory?: ShopCategory;
|
||||
}
|
||||
|
||||
export interface UpdateProductInput extends Partial<CreateProductInput> {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
BatchStock,
|
||||
CorrectInput,
|
||||
StockBatchPath,
|
||||
WithdrawInput,
|
||||
} from "./types";
|
||||
|
||||
export function fetchStock(path: StockBatchPath, id: string): Promise<BatchStock> {
|
||||
return apiFetch<BatchStock>(`/${path}/${id}/stock`);
|
||||
}
|
||||
|
||||
export function withdrawStock(
|
||||
path: StockBatchPath,
|
||||
id: string,
|
||||
input: WithdrawInput,
|
||||
): Promise<BatchStock> {
|
||||
return apiFetch<BatchStock>(`/${path}/${id}/withdrawals`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function correctStock(
|
||||
path: StockBatchPath,
|
||||
id: string,
|
||||
input: CorrectInput,
|
||||
): Promise<BatchStock> {
|
||||
return apiFetch<BatchStock>(`/${path}/${id}/corrections`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MovementType, WithdrawalReason } from "./types";
|
||||
|
||||
export const WITHDRAWAL_REASON_LABELS: Record<WithdrawalReason, string> = {
|
||||
HOFLADEN: "Hofladen",
|
||||
EIGENBEDARF: "Eigenbedarf / Geschenk",
|
||||
BRUCH: "Bruch / Verlust",
|
||||
PROBE: "Probe / Muster",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
|
||||
SHOP_SALE: "Webshop-Verkauf",
|
||||
SHOP_RETURN: "Webshop-Rückbuchung",
|
||||
WITHDRAWAL: "Entnahme",
|
||||
CORRECTION: "Inventur",
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Collection path of a batch whose stock is tracked in the movement ledger. */
|
||||
export type StockBatchPath = "bottling-batches" | "production-batches";
|
||||
|
||||
export type WithdrawalReason =
|
||||
| "HOFLADEN"
|
||||
| "EIGENBEDARF"
|
||||
| "BRUCH"
|
||||
| "PROBE"
|
||||
| "SONSTIGES";
|
||||
|
||||
export type MovementType =
|
||||
| "SHOP_SALE"
|
||||
| "SHOP_RETURN"
|
||||
| "WITHDRAWAL"
|
||||
| "CORRECTION";
|
||||
|
||||
export interface StockSummary {
|
||||
/** Bottled or produced. */
|
||||
initial: number;
|
||||
/** Net shop sales (sales minus shop returns). */
|
||||
soldInShop: number;
|
||||
withdrawn: number;
|
||||
/** Net inventory corrections. */
|
||||
corrected: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface StockMovement {
|
||||
id: string;
|
||||
type: MovementType;
|
||||
/** Signed: negative takes stock out. */
|
||||
quantity: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
apiKeyId: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string } | null;
|
||||
shopRequest: { externalId: string; kind: "SALE" | "RETURN" } | null;
|
||||
}
|
||||
|
||||
export interface BatchStock {
|
||||
summary: StockSummary;
|
||||
movements: StockMovement[];
|
||||
}
|
||||
|
||||
export interface WithdrawInput {
|
||||
quantity: number;
|
||||
reason: WithdrawalReason;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CorrectInput {
|
||||
countedQuantity: number;
|
||||
notes: string;
|
||||
}
|
||||
Reference in new issue
Block a user