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:
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 = [
|
const MORE_LINKS = [
|
||||||
{ label: "Produkte", href: "/products" },
|
{ label: "Produkte", href: "/products" },
|
||||||
{ label: "Abfüllungen", href: "/bottling/new" },
|
{ label: "Abfüllungen", href: "/bottling/new" },
|
||||||
|
{ label: "Inventar", href: "/equipment" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ADMIN_LINKS = [
|
const ADMIN_LINKS = [
|
||||||
|
|||||||
@@ -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,16 +5,19 @@ const WARNING_STATUSES = new Set([
|
|||||||
"CONSPICUOUS",
|
"CONSPICUOUS",
|
||||||
"QUEENLESS",
|
"QUEENLESS",
|
||||||
"LOW_STOCK",
|
"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 NEUTRAL_STATUSES = new Set(["MERGED", "CONSUMED"]);
|
||||||
|
|
||||||
|
const POSITIVE_STATUSES = new Set(["ACTIVE", "NEW", "GOOD"]);
|
||||||
|
|
||||||
export function statusTone(status: string): StatusTone {
|
export function statusTone(status: string): StatusTone {
|
||||||
if (WARNING_STATUSES.has(status)) return "warning";
|
if (WARNING_STATUSES.has(status)) return "warning";
|
||||||
if (DESTRUCTIVE_STATUSES.has(status)) return "destructive";
|
if (DESTRUCTIVE_STATUSES.has(status)) return "destructive";
|
||||||
if (NEUTRAL_STATUSES.has(status)) return "outline";
|
if (NEUTRAL_STATUSES.has(status)) return "outline";
|
||||||
if (status === "ACTIVE") return "default";
|
if (POSITIVE_STATUSES.has(status)) return "default";
|
||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
@@ -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`
|
- Endpunkte: `GET/POST /api/v1/equipment`, `GET/PATCH /api/v1/equipment/:id`
|
||||||
- Frontend: `/equipment`, `/equipment/new`, `/equipment/:id`
|
- Frontend: `/equipment`, `/equipment/new`, `/equipment/:id`
|
||||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
- [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
|
### 25. Kalender
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user