From 8b42a86115de6f13314c4adcc2431a8c56848695 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 22:30:46 +0200 Subject: [PATCH] feat(frontend): Inventar (Modul 24) /equipment, /equipment/new, /equipment/:id mit Zustandsaenderung. statusTone()-Helper um Equipment-Zustaende (NEW/GOOD/WORN/DEFECTIVE) erweitert, Nav-Dropdown um Inventar ergaenzt. --- .../src/app/(app)/equipment/[id]/page.tsx | 94 ++++++++++++++ .../src/app/(app)/equipment/new/page.tsx | 122 ++++++++++++++++++ .../src/app/(app)/equipment/page.tsx | 73 +++++++++++ ApiCore-Frontend/src/app/(app)/layout.tsx | 1 + ApiCore-Frontend/src/lib/equipment/api.ts | 35 +++++ ApiCore-Frontend/src/lib/equipment/types.ts | 31 +++++ ApiCore-Frontend/src/lib/status-tone.ts | 7 +- TASKS.md | 2 +- 8 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/equipment/new/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/equipment/page.tsx create mode 100644 ApiCore-Frontend/src/lib/equipment/api.ts create mode 100644 ApiCore-Frontend/src/lib/equipment/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx new file mode 100644 index 0000000..c18bf8c --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; +import { EquipmentCondition } from "@/lib/equipment/types"; +import { statusTone } from "@/lib/status-tone"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +const CONDITIONS: EquipmentCondition[] = ["NEW", "GOOD", "WORN", "DEFECTIVE"]; + +export default function EquipmentDetailPage() { + const params = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const { data: equipment, isLoading, isError } = useQuery({ + queryKey: ["equipment", params.id], + queryFn: () => fetchEquipment(params.id), + }); + + const updateMutation = useMutation({ + mutationFn: (condition: EquipmentCondition) => + updateEquipment(params.id, { condition }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["equipment", params.id] }); + void queryClient.invalidateQueries({ queryKey: ["equipment"] }); + }, + }); + + if (isLoading) { + return

Lädt…

; + } + if (isError || !equipment) { + return ( +

+ Gegenstand konnte nicht geladen werden. +

+ ); + } + + return ( +
+ + + {equipment.name} + {equipment.condition} + + +

+ Inventarnummer: {equipment.inventoryNumber} +

+ {equipment.manufacturer ?

Hersteller: {equipment.manufacturer}

: null} + {equipment.location ?

Standort: {equipment.location}

: null} + {equipment.purchaseDate ? ( +

Kaufdatum: {new Date(equipment.purchaseDate).toLocaleDateString("de-DE")}

+ ) : null} + {equipment.maintenanceDate ? ( +

+ Wartungsdatum:{" "} + {new Date(equipment.maintenanceDate).toLocaleDateString("de-DE")} +

+ ) : null} +
+ Zustand ändern + +
+
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/equipment/new/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/new/page.tsx new file mode 100644 index 0000000..aab866b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/equipment/new/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createEquipment } from "@/lib/equipment/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function NewEquipmentPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [inventoryNumber, setInventoryNumber] = useState(""); + const [name, setName] = useState(""); + const [manufacturer, setManufacturer] = useState(""); + const [purchaseDate, setPurchaseDate] = useState(""); + const [location, setLocation] = useState(""); + const [maintenanceDate, setMaintenanceDate] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: createEquipment, + onSuccess: (equipment) => { + void queryClient.invalidateQueries({ queryKey: ["equipment"] }); + router.push(`/equipment/${equipment.id}`); + }, + onError: () => { + setError("Gegenstand konnte nicht angelegt werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate({ + inventoryNumber, + name, + manufacturer: manufacturer || undefined, + purchaseDate: purchaseDate ? new Date(purchaseDate).toISOString() : undefined, + location: location || undefined, + maintenanceDate: maintenanceDate + ? new Date(maintenanceDate).toISOString() + : undefined, + }); + } + + return ( +
+ + + Neuer Inventargegenstand + + +
+
+ + setInventoryNumber(event.target.value)} + /> +
+
+ + setName(event.target.value)} + /> +
+
+ + setManufacturer(event.target.value)} + /> +
+
+ + setPurchaseDate(event.target.value)} + /> +
+
+ + setLocation(event.target.value)} + /> +
+
+ + setMaintenanceDate(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/equipment/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/page.tsx new file mode 100644 index 0000000..1b56fe5 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/equipment/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { fetchEquipmentList } from "@/lib/equipment/api"; +import { statusTone } from "@/lib/status-tone"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function EquipmentPage() { + const { data: equipmentList, isLoading, isError } = useQuery({ + queryKey: ["equipment"], + queryFn: () => fetchEquipmentList(), + }); + + return ( +
+
+

Inventar

+ +
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Inventar konnte nicht geladen werden. +

+ ) : null} + + {equipmentList ? ( + + + + Inventarnummer + Bezeichnung + Standort + Zustand + + + + {equipmentList.map((item) => ( + + + + {item.inventoryNumber} + + + {item.name} + {item.location ?? "—"} + + {item.condition} + + + ))} + +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 4a5f4a3..e0a0578 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -32,6 +32,7 @@ const PRIMARY_LINKS = [ const MORE_LINKS = [ { label: "Produkte", href: "/products" }, { label: "Abfüllungen", href: "/bottling/new" }, + { label: "Inventar", href: "/equipment" }, ]; const ADMIN_LINKS = [ diff --git a/ApiCore-Frontend/src/lib/equipment/api.ts b/ApiCore-Frontend/src/lib/equipment/api.ts new file mode 100644 index 0000000..255f51f --- /dev/null +++ b/ApiCore-Frontend/src/lib/equipment/api.ts @@ -0,0 +1,35 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { + CreateEquipmentInput, + Equipment, + EquipmentFilter, + UpdateEquipmentInput, +} from "./types"; + +export function fetchEquipmentList(filter: EquipmentFilter = {}): Promise { + const params = new URLSearchParams(); + if (filter.condition) params.set("condition", filter.condition); + const query = params.toString(); + return apiFetch(`/equipment${query ? `?${query}` : ""}`); +} + +export function fetchEquipment(id: string): Promise { + return apiFetch(`/equipment/${id}`); +} + +export function createEquipment(input: CreateEquipmentInput): Promise { + return apiFetch("/equipment", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateEquipment( + id: string, + input: UpdateEquipmentInput, +): Promise { + return apiFetch(`/equipment/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} diff --git a/ApiCore-Frontend/src/lib/equipment/types.ts b/ApiCore-Frontend/src/lib/equipment/types.ts new file mode 100644 index 0000000..2c4edf9 --- /dev/null +++ b/ApiCore-Frontend/src/lib/equipment/types.ts @@ -0,0 +1,31 @@ +export type EquipmentCondition = "NEW" | "GOOD" | "WORN" | "DEFECTIVE"; + +export interface Equipment { + id: string; + inventoryNumber: string; + name: string; + manufacturer: string | null; + purchaseDate: string | null; + condition: EquipmentCondition; + location: string | null; + maintenanceDate: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateEquipmentInput { + inventoryNumber: string; + name: string; + manufacturer?: string; + purchaseDate?: string; + location?: string; + maintenanceDate?: string; +} + +export interface UpdateEquipmentInput extends Partial { + condition?: EquipmentCondition; +} + +export interface EquipmentFilter { + condition?: EquipmentCondition; +} diff --git a/ApiCore-Frontend/src/lib/status-tone.ts b/ApiCore-Frontend/src/lib/status-tone.ts index 433d8f4..406b1b5 100644 --- a/ApiCore-Frontend/src/lib/status-tone.ts +++ b/ApiCore-Frontend/src/lib/status-tone.ts @@ -5,16 +5,19 @@ const WARNING_STATUSES = new Set([ "CONSPICUOUS", "QUEENLESS", "LOW_STOCK", + "WORN", ]); -const DESTRUCTIVE_STATUSES = new Set(["DISSOLVED", "DECEASED", "SOLD"]); +const DESTRUCTIVE_STATUSES = new Set(["DISSOLVED", "DECEASED", "SOLD", "DEFECTIVE"]); const NEUTRAL_STATUSES = new Set(["MERGED", "CONSUMED"]); +const POSITIVE_STATUSES = new Set(["ACTIVE", "NEW", "GOOD"]); + export function statusTone(status: string): StatusTone { if (WARNING_STATUSES.has(status)) return "warning"; if (DESTRUCTIVE_STATUSES.has(status)) return "destructive"; if (NEUTRAL_STATUSES.has(status)) return "outline"; - if (status === "ACTIVE") return "default"; + if (POSITIVE_STATUSES.has(status)) return "default"; return "secondary"; } diff --git a/TASKS.md b/TASKS.md index 8afcdc1..9723d8b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -196,7 +196,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Endpunkte: `GET/POST /api/v1/equipment`, `GET/PATCH /api/v1/equipment/:id` - Frontend: `/equipment`, `/equipment/new`, `/equipment/:id` - [x] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 25. Kalender