feat(inventory): add spare parts management
This commit is contained in:
parent
abc6e308dd
commit
fb5c2cc26a
43 changed files with 2950 additions and 2 deletions
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue