From f57db97578936ed85b96923066c1e67f18566cc9 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 2 Oct 2026 06:44:21 +0200 Subject: [PATCH] feat(ui): stock card with withdraw and inventory count, production batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/app/(app)/bottling/[id]/page.tsx | 8 + .../src/app/(app)/bottling/new/page.tsx | 4 +- .../src/app/(app)/bottling/page.tsx | 7 + .../(app)/production-batches/[id]/page.tsx | 85 ++++ .../app/(app)/production-batches/new/page.tsx | 166 +++++++ .../src/app/(app)/production-batches/page.tsx | 73 ++++ .../(app)/products/edit-product-dialog.tsx | 33 +- .../src/app/(app)/products/new/page.spec.tsx | 2 + .../src/app/(app)/products/new/page.tsx | 54 +++ .../(app)/products/new/product-form-values.ts | 12 +- ApiCore-Frontend/src/app/(app)/sidebar.tsx | 7 + .../src/components/stock/stock-card.spec.tsx | 109 +++++ .../src/components/stock/stock-card.tsx | 406 ++++++++++++++++++ ApiCore-Frontend/src/lib/bottling/types.ts | 2 + .../src/lib/production-batches/api.ts | 36 ++ .../src/lib/production-batches/types.ts | 25 ++ ApiCore-Frontend/src/lib/products/labels.ts | 14 +- ApiCore-Frontend/src/lib/products/types.ts | 9 + ApiCore-Frontend/src/lib/stock/api.ts | 33 ++ ApiCore-Frontend/src/lib/stock/labels.ts | 16 + ApiCore-Frontend/src/lib/stock/types.ts | 55 +++ 21 files changed, 1151 insertions(+), 5 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/production-batches/page.tsx create mode 100644 ApiCore-Frontend/src/components/stock/stock-card.spec.tsx create mode 100644 ApiCore-Frontend/src/components/stock/stock-card.tsx create mode 100644 ApiCore-Frontend/src/lib/production-batches/api.ts create mode 100644 ApiCore-Frontend/src/lib/production-batches/types.ts create mode 100644 ApiCore-Frontend/src/lib/stock/api.ts create mode 100644 ApiCore-Frontend/src/lib/stock/labels.ts create mode 100644 ApiCore-Frontend/src/lib/stock/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx index db4866b..50c02d6 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx @@ -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() {

+ diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx index 498108a..a3a5c90 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx @@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() { - {products?.map((product) => ( + {products + ?.filter((product) => product.kind !== "MANUFACTURED") + .map((product) => ( {product.name} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx index ad9a778..5c3e8d8 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx @@ -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) }, ]} /> diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx new file mode 100644 index 0000000..0db409b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/page.tsx @@ -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 ; + } + + return ( +
+ +
+ + + Angaben + {hasPermission("production-batch:delete") ? ( + deletion.mutate()} + isPending={deletion.isPending} + /> + ) : null} + + +

Artikel: {batch.product.name}

+

Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}

+ {batch.bestBeforeDate ? ( +

Mindesthaltbar bis: {new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}

+ ) : null} + {batch.notes ?

Notiz: {batch.notes}

: null} +
+
+ +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx new file mode 100644 index 0000000..f4fe2c1 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx @@ -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(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) { + event.preventDefault(); + setError(null); + if (!productId) { + setError("Bitte einen Artikel wählen."); + return; + } + mutation.mutate(); + } + + return ( +
+ + + +
+
+ + + {products && manufactured.length === 0 ? ( +

+ Noch kein Artikel der Art „Hergestellte Ware“. Lege ihn unter Produkte an. +

+ ) : null} +
+
+ + setLotNumber(event.target.value)} + /> +
+
+ + setDate(event.target.value)} + /> +
+
+ + setQuantity(event.target.value)} + /> +
+
+ + setBestBeforeDate(event.target.value)} + /> +
+
+ +