From 7ec20199d3511035eb054e0d7a88dae7ea13d7eb Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 05:12:23 +0200 Subject: [PATCH] feat(frontend): Lagerverwaltung-Ansicht (Modul 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /storage mit Lagerbehälter- und Lagerartikelverwaltung inkl. Bestandsbewegungen (Zugang/Abgang/Umlagerung/Korrektur). --- ApiCore-Frontend/src/app/(app)/layout.tsx | 3 + .../storage/create-stock-item-dialog.tsx | 131 ++++++++++++++++++ .../create-storage-container-dialog.tsx | 111 +++++++++++++++ .../src/app/(app)/storage/page.tsx | 104 ++++++++++++++ .../(app)/storage/record-movement-dialog.tsx | 110 +++++++++++++++ ApiCore-Frontend/src/lib/storage/api.ts | 42 ++++++ ApiCore-Frontend/src/lib/storage/types.ts | 57 ++++++++ TASKS.md | 2 +- 8 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Frontend/src/app/(app)/storage/create-stock-item-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/storage/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx create mode 100644 ApiCore-Frontend/src/lib/storage/api.ts create mode 100644 ApiCore-Frontend/src/lib/storage/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index be666f0..829f3d3 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -43,6 +43,9 @@ export default function AppLayout({ children }: { children: ReactNode }) { > Honigchargen + + Lager + {user.role === "ADMIN" ? ( ("CONSUMABLE"); + const [unit, setUnit] = useState(""); + const [quantity, setQuantity] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: createStockItem, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["stock-items"] }); + setOpen(false); + setName(""); + setUnit(""); + setQuantity(""); + setError(null); + }, + onError: () => { + setError("Lagerartikel konnte nicht angelegt werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate({ + name, + category, + unit, + quantity: quantity ? Number(quantity) : undefined, + }); + } + + return ( + + + Neuer Artikel} /> + + + Neuer Lagerartikel + +
+
+ + setName(event.target.value)} + /> +
+
+ + +
+
+ + setUnit(event.target.value)} + /> +
+
+ + setQuantity(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx new file mode 100644 index 0000000..777d161 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createStorageContainer } from "@/lib/storage/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export function CreateStorageContainerDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [code, setCode] = useState(""); + const [containerType, setContainerType] = useState(""); + const [capacity, setCapacity] = useState(""); + const [location, setLocation] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: createStorageContainer, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["storage-containers"] }); + setOpen(false); + setCode(""); + setContainerType(""); + setCapacity(""); + setLocation(""); + setError(null); + }, + onError: () => { + setError("Lagerbehälter konnte nicht angelegt werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate({ + code, + containerType: containerType || undefined, + capacity: capacity ? Number(capacity) : undefined, + location: location || undefined, + }); + } + + return ( + + + Neuer Behälter} /> + + + Neuer Lagerbehälter + +
+
+ + setCode(event.target.value)} + /> +
+
+ + setContainerType(event.target.value)} + /> +
+
+ + setCapacity(event.target.value)} + /> +
+
+ + setLocation(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/storage/page.tsx b/ApiCore-Frontend/src/app/(app)/storage/page.tsx new file mode 100644 index 0000000..2e922e0 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/storage/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CreateStorageContainerDialog } from "./create-storage-container-dialog"; +import { CreateStockItemDialog } from "./create-stock-item-dialog"; +import { RecordMovementDialog } from "./record-movement-dialog"; + +export default function StoragePage() { + const { data: containers, isLoading: containersLoading } = useQuery({ + queryKey: ["storage-containers"], + queryFn: fetchStorageContainers, + }); + const { data: stockItems, isLoading: itemsLoading } = useQuery({ + queryKey: ["stock-items"], + queryFn: fetchStockItems, + }); + + return ( +
+
+
+

Lagerbehälter

+ +
+ {containersLoading ?

Lädt…

: null} + {containers ? ( + + + + Code + Typ + Standort + Status + + + + {containers.map((container) => ( + + {container.code} + {container.containerType ?? "—"} + {container.location ?? "—"} + + + {container.status} + + + + ))} + +
+ ) : null} +
+ +
+
+

Lagerartikel

+ +
+ {itemsLoading ?

Lädt…

: null} + {stockItems ? ( + + + + Name + Kategorie + Bestand + Aktion + + + + {stockItems.map((item) => ( + + {item.name} + {item.category} + + {item.quantity} {item.unit} + {item.minThreshold !== null && item.quantity < item.minThreshold ? ( + + Niedrig + + ) : null} + + + + + + ))} + +
+ ) : null} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx new file mode 100644 index 0000000..2fc18d1 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { addStockMovement } from "@/lib/storage/api"; +import { StockMovementType } from "@/lib/storage/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const TYPE_OPTIONS: StockMovementType[] = ["IN", "OUT", "TRANSFER", "ADJUSTMENT"]; + +export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [type, setType] = useState("IN"); + const [quantity, setQuantity] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: (input: { type: StockMovementType; quantity: number }) => + addStockMovement(stockItemId, input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["stock-items"] }); + setOpen(false); + setQuantity(""); + setError(null); + }, + onError: () => { + setError("Bewegung konnte nicht gespeichert werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (!quantity) { + setError("Bitte eine Menge angeben."); + return; + } + mutation.mutate({ type, quantity: Number(quantity) }); + } + + return ( + + Bewegung buchen} /> + + + Bestandsbewegung + +
+
+ + +
+
+ + setQuantity(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/lib/storage/api.ts b/ApiCore-Frontend/src/lib/storage/api.ts new file mode 100644 index 0000000..52d4293 --- /dev/null +++ b/ApiCore-Frontend/src/lib/storage/api.ts @@ -0,0 +1,42 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { + CreateStockItemInput, + CreateStockMovementInput, + CreateStorageContainerInput, + StockItem, + StorageContainer, +} from "./types"; + +export function fetchStorageContainers(): Promise { + return apiFetch("/storage-containers"); +} + +export function createStorageContainer( + input: CreateStorageContainerInput, +): Promise { + return apiFetch("/storage-containers", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function fetchStockItems(): Promise { + return apiFetch("/stock-items"); +} + +export function createStockItem(input: CreateStockItemInput): Promise { + return apiFetch("/stock-items", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function addStockMovement( + id: string, + input: CreateStockMovementInput, +): Promise { + return apiFetch(`/stock-items/${id}/movements`, { + method: "POST", + body: JSON.stringify(input), + }); +} diff --git a/ApiCore-Frontend/src/lib/storage/types.ts b/ApiCore-Frontend/src/lib/storage/types.ts new file mode 100644 index 0000000..51f1bc1 --- /dev/null +++ b/ApiCore-Frontend/src/lib/storage/types.ts @@ -0,0 +1,57 @@ +export type StockCategory = "HONEY" | "CONSUMABLE" | "EQUIPMENT_MATERIAL"; +export type StockMovementType = "IN" | "OUT" | "TRANSFER" | "ADJUSTMENT"; +export type StorageContainerStatus = "EMPTY" | "FILLED" | "CLEANING" | "OUT_OF_SERVICE"; + +export interface StorageContainer { + id: string; + code: string; + containerType: string | null; + capacity: number | null; + currentAmount: number | null; + location: string | null; + status: StorageContainerStatus; +} + +export interface StockMovement { + id: string; + stockItemId: string; + type: StockMovementType; + quantity: number; + date: string; + relatedType: string | null; + relatedId: string | null; + notes: string | null; +} + +export interface StockItem { + id: string; + name: string; + category: StockCategory; + unit: string; + quantity: number; + minThreshold: number | null; + notes: string | null; + movements?: StockMovement[]; +} + +export interface CreateStorageContainerInput { + code: string; + containerType?: string; + capacity?: number; + location?: string; +} + +export interface CreateStockItemInput { + name: string; + category: StockCategory; + unit: string; + quantity?: number; + minThreshold?: number; + notes?: string; +} + +export interface CreateStockMovementInput { + type: StockMovementType; + quantity: number; + notes?: string; +} diff --git a/TASKS.md b/TASKS.md index b1744e8..30f72e2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -107,7 +107,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Endpunkte: `GET/POST /api/v1/storage-containers`, `GET/POST /api/v1/stock-items`, `POST /api/v1/stock-items/:id/movements` - Frontend: `/storage` - [x] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 13. Dashboard