Olympus/frontend/athena/app/inventory/items/[id]/page.tsx
2026-07-05 11:32:06 +02:00

153 lines
6.3 KiB
TypeScript

"use client";
import { use, useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { formatDate, formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { InventoryItem, InventoryStockMovement } from "@/types/inventory";
type PageProps = {
params: Promise<{
id: string;
}>;
};
const movementLabels: Record<string, string> = {
initial: "Anfangsbestand",
purchase: "Einkauf",
adjustment: "Anpassung",
reservation: "Reservierung",
release: "Freigabe",
consumption: "Verbrauch",
return: "Rückgabe",
correction: "Korrektur",
};
export default function InventoryItemDetailPage({ params }: PageProps) {
const { id } = use(params);
const [item, setItem] = useState<InventoryItem | null>(null);
const [movements, setMovements] = useState<InventoryStockMovement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const loadItem = useCallback(async () => {
setLoading(true);
setError("");
try {
const [itemResponse, movementResponse] = await Promise.all([
api.get<InventoryItem>(`/inventory/items/${id}`),
api.get<InventoryStockMovement[]>(`/inventory/items/${id}/movements`),
]);
setItem(itemResponse.data);
setMovements(movementResponse.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
queueMicrotask(() => {
void loadItem();
});
}, [loadItem]);
if (loading) {
return <div className="rounded-lg border bg-white p-8 text-slate-500">Artikel wird geladen...</div>;
}
if (error || !item) {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Artikel nicht gefunden"}</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-medium text-slate-500">{item.sku}</p>
<h1 className="text-3xl font-bold text-slate-950">{item.name}</h1>
<p className="mt-1 text-sm text-slate-500">{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}</p>
</div>
<Link href="/inventory"><Button type="button" variant="outline">Zurück zum Lager</Button></Link>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
<h2 className="font-semibold text-slate-950">Stammdaten</h2>
<dl className="mt-4 grid gap-4 md:grid-cols-2">
<Info label="Beschreibung" value={item.description ?? "Keine Angabe"} />
<Info label="Hersteller" value={item.manufacturer ?? "Keine Angabe"} />
<Info label="Hersteller-Teilenummer" value={item.manufacturer_part_number ?? "Keine Angabe"} />
<Info label="Lieferanten-Teilenummer" value={item.supplier_part_number ?? "Keine Angabe"} />
<Info label="Lieferant" value={item.supplier?.name ?? "Kein Lieferant"} />
<Info label="Lagerort" value={item.location?.name ?? "Kein Lagerort"} />
<Info label="Kompatible Geräte" value={item.compatible_devices ?? "Keine Angabe"} />
<Info label="Notizen" value={item.notes ?? "Keine Notizen"} />
</dl>
</section>
<section className="rounded-lg border bg-white p-5">
<h2 className="font-semibold text-slate-950">Bestand</h2>
<dl className="mt-4 grid gap-3">
<Metric label="Bestand" value={`${item.quantity_on_hand} ${item.unit}`} />
<Metric label="Reserviert" value={`${item.quantity_reserved} ${item.unit}`} />
<Metric label="Verfügbar" value={`${item.quantity_available} ${item.unit}`} />
<Metric label="Mindestbestand" value={`${item.reorder_level} ${item.unit}`} />
</dl>
</section>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<section className="rounded-lg border bg-white p-5">
<h2 className="font-semibold text-slate-950">Preise</h2>
<dl className="mt-4 grid gap-3">
<Metric label="Einkaufspreis" value={formatMoney(item.purchase_price_cents, item.currency)} />
<Metric label="Verkaufspreis" value={formatMoney(item.selling_price_cents, item.currency)} />
<Metric label="MwSt." value={`${item.tax_rate_percent} %`} />
</dl>
</section>
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
<h2 className="font-semibold text-slate-950">Bewegungen</h2>
{movements.length === 0 ? (
<p className="mt-4 text-sm text-slate-500">Noch keine Bewegungen vorhanden.</p>
) : (
<div className="mt-4 divide-y">
{movements.map((movement) => (
<div key={movement.id} className="py-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}</p>
<time className="text-xs text-slate-500" dateTime={movement.created_at}>{formatDate(movement.created_at)}</time>
</div>
<p className="mt-1 text-sm text-slate-500">{movement.reason}{movement.note ? ` · ${movement.note}` : ""}</p>
{movement.actor_username && <p className="mt-1 text-xs text-slate-400">Gebucht von {movement.actor_username}</p>}
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{value}</dd>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-slate-50 p-3">
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
<dd className="mt-1 text-lg font-semibold text-slate-950">{value}</dd>
</div>
);
}