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.
This commit is contained in:
Nick Szaunig committed 2026-08-22 22:30:46 +02:00
1 parent 3f20133d02
commit 8b42a86115
8 files changed
+362 -3

No files matched your search

@@ -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 <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !equipment) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Gegenstand konnte nicht geladen werden.
</p>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{equipment.name}</CardTitle>
<Badge variant={statusTone(equipment.condition)}>{equipment.condition}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
Inventarnummer: <span className="font-mono">{equipment.inventoryNumber}</span>
</p>
{equipment.manufacturer ? <p>Hersteller: {equipment.manufacturer}</p> : null}
{equipment.location ? <p>Standort: {equipment.location}</p> : null}
{equipment.purchaseDate ? (
<p>Kaufdatum: {new Date(equipment.purchaseDate).toLocaleDateString("de-DE")}</p>
) : null}
{equipment.maintenanceDate ? (
<p>
Wartungsdatum:{" "}
{new Date(equipment.maintenanceDate).toLocaleDateString("de-DE")}
</p>
) : null}
<div className="flex flex-col gap-2 pt-2">
<span className="text-muted-foreground">Zustand ändern</span>
<Select
value={equipment.condition}
onValueChange={(value) =>
value ? updateMutation.mutate(value as EquipmentCondition) : undefined
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITIONS.map((condition) => (
<SelectItem key={condition} value={condition}>
{condition}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</main>
);
}
@@ -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<string | null>(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<HTMLFormElement>) {
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 (
<main className="p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Neuer Inventargegenstand</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-inventory-number">Inventarnummer</Label>
<Input
id="equipment-inventory-number"
required
value={inventoryNumber}
onChange={(event) => setInventoryNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-name">Bezeichnung</Label>
<Input
id="equipment-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-manufacturer">Hersteller</Label>
<Input
id="equipment-manufacturer"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-purchase-date">Kaufdatum</Label>
<Input
id="equipment-purchase-date"
type="date"
value={purchaseDate}
onChange={(event) => setPurchaseDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-location">Standort</Label>
<Input
id="equipment-location"
value={location}
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-maintenance-date">Wartungsdatum</Label>
<Input
id="equipment-maintenance-date"
type="date"
value={maintenanceDate}
onChange={(event) => setMaintenanceDate(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -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 (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Inventar</h1>
<Button nativeButton={false} render={<Link href="/equipment/new" />}>
+ Neuer Gegenstand
</Button>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Inventar konnte nicht geladen werden.
</p>
) : null}
{equipmentList ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Inventarnummer</TableHead>
<TableHead>Bezeichnung</TableHead>
<TableHead>Standort</TableHead>
<TableHead>Zustand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{equipmentList.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Link
className="font-mono font-medium text-primary underline-offset-4 hover:underline"
href={`/equipment/${item.id}`}
>
{item.inventoryNumber}
</Link>
</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.location ?? "—"}</TableCell>
<TableCell>
<Badge variant={statusTone(item.condition)}>{item.condition}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</main>
);
}
@@ -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 = [
+35
View File
@@ -0,0 +1,35 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateEquipmentInput,
Equipment,
EquipmentFilter,
UpdateEquipmentInput,
} from "./types";
export function fetchEquipmentList(filter: EquipmentFilter = {}): Promise<Equipment[]> {
const params = new URLSearchParams();
if (filter.condition) params.set("condition", filter.condition);
const query = params.toString();
return apiFetch<Equipment[]>(`/equipment${query ? `?${query}` : ""}`);
}
export function fetchEquipment(id: string): Promise<Equipment> {
return apiFetch<Equipment>(`/equipment/${id}`);
}
export function createEquipment(input: CreateEquipmentInput): Promise<Equipment> {
return apiFetch<Equipment>("/equipment", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateEquipment(
id: string,
input: UpdateEquipmentInput,
): Promise<Equipment> {
return apiFetch<Equipment>(`/equipment/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -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<CreateEquipmentInput> {
condition?: EquipmentCondition;
}
export interface EquipmentFilter {
condition?: EquipmentCondition;
}
+5 -2
View File
@@ -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";
}
+1 -1
View File
@@ -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