feat(inventory): add spare parts management
This commit is contained in:
parent
abc6e308dd
commit
fb5c2cc26a
43 changed files with 2950 additions and 2 deletions
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyCategoryRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/categories/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCategoryRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCategoryRequest(request, context);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/categories");
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/movements`);
|
||||
}
|
||||
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyItemRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyItemRequest(request, context);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/items");
|
||||
}
|
||||
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxyLocationRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/locations/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyLocationRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyLocationRequest(request, context);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/locations");
|
||||
}
|
||||
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
async function proxySupplierRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/inventory/suppliers/${id}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxySupplierRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxySupplierRequest(request, context);
|
||||
}
|
||||
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/inventory/suppliers");
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export default function DashboardPage() {
|
|||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||
}
|
||||
|
||||
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
|
||||
const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
322
frontend/athena/app/inventory/page.tsx
Normal file
322
frontend/athena/app/inventory/page.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import EmptyState from "@/components/common/EmptyState";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import InventoryItemDialog from "@/components/inventory/InventoryItemDialog";
|
||||
import MasterDataSection from "@/components/inventory/MasterDataSection";
|
||||
import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog";
|
||||
import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
InventoryCategory,
|
||||
InventoryItem,
|
||||
InventoryItemListResponse,
|
||||
InventoryItemPayload,
|
||||
InventoryLocation,
|
||||
InventoryStockPayload,
|
||||
InventorySupplier,
|
||||
} from "@/types/inventory";
|
||||
import type { CurrentUser } from "@/types/rbac";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
export default function InventoryPage() {
|
||||
const { showToast } = useToast();
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [categories, setCategories] = useState<InventoryCategory[]>([]);
|
||||
const [locations, setLocations] = useState<InventoryLocation[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<InventorySupplier[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [stockError, setStockError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items");
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [locationFilter, setLocationFilter] = useState("all");
|
||||
const [supplierFilter, setSupplierFilter] = useState("all");
|
||||
const [stockFilter, setStockFilter] = useState<"all" | "low">("all");
|
||||
const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [stockOpen, setStockOpen] = useState(false);
|
||||
const [stockAction, setStockAction] = useState<StockAction>("adjust");
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
const [deleteItem, setDeleteItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(pageSize));
|
||||
params.set("offset", String((page - 1) * pageSize));
|
||||
if (search.trim()) params.set("q", search.trim());
|
||||
if (categoryFilter !== "all") params.set("category_id", categoryFilter);
|
||||
if (locationFilter !== "all") params.set("location_id", locationFilter);
|
||||
if (supplierFilter !== "all") params.set("supplier_id", supplierFilter);
|
||||
if (stockFilter === "low") params.set("low_stock", "true");
|
||||
if (activeFilter !== "all") params.set("active", activeFilter);
|
||||
const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([
|
||||
api.get<InventoryItemListResponse>(`/inventory/items?${params.toString()}`),
|
||||
api.get<InventoryCategory[]>("/inventory/categories"),
|
||||
api.get<InventoryLocation[]>("/inventory/locations"),
|
||||
api.get<InventorySupplier[]>("/inventory/suppliers"),
|
||||
api.get<CurrentUser>("/me"),
|
||||
]);
|
||||
setItems(itemResponse.data.items);
|
||||
setTotal(itemResponse.data.total);
|
||||
setCategories(categoryResponse.data);
|
||||
setLocations(locationResponse.data);
|
||||
setSuppliers(supplierResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadData();
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
const canCreate = hasPermission(currentUser, "inventory.create");
|
||||
const canUpdate = hasPermission(currentUser, "inventory.update");
|
||||
const canDelete = hasPermission(currentUser, "inventory.delete");
|
||||
const canAdjust = hasPermission(currentUser, "inventory.stock.adjust");
|
||||
const canReserve = hasPermission(currentUser, "inventory.stock.reserve");
|
||||
const canConsume = hasPermission(currentUser, "inventory.stock.consume");
|
||||
const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata");
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, pageCount);
|
||||
|
||||
const sortedItems = useMemo(() => [...items].sort((first, second) => {
|
||||
const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase();
|
||||
const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase();
|
||||
if (firstValue === secondValue) return 0;
|
||||
const result = firstValue > secondValue ? 1 : -1;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
}), [items, sortDirection, sortKey]);
|
||||
|
||||
const columns: DataTableColumn<InventoryItem>[] = [
|
||||
{ key: "sku", label: "SKU", sortable: true, render: (item) => <span className="font-medium text-slate-950">{item.sku}</span> },
|
||||
{ key: "name", label: "Name", sortable: true, render: (item) => item.name },
|
||||
{ key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" },
|
||||
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" },
|
||||
{ key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand },
|
||||
{ key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved },
|
||||
{ key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => <span className={item.quantity_available <= item.reorder_level ? "font-semibold text-amber-700" : ""}>{item.quantity_available}</span> },
|
||||
{ key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level },
|
||||
{ key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" },
|
||||
{ key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) },
|
||||
{ key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Aktionen",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (item) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/inventory/items/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedItem(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
|
||||
{canAdjust && <Button type="button" variant="ghost" size="icon-sm" title="Bestand anpassen" onClick={() => openStock(item, "adjust")}><Wrench size={16} /></Button>}
|
||||
{canReserve && <Button type="button" variant="ghost" size="icon-sm" title="Reservieren" onClick={() => openStock(item, "reserve")}><PackagePlus size={16} /></Button>}
|
||||
{canConsume && <Button type="button" variant="ghost" size="icon-sm" title="Verbrauch buchen" onClick={() => openStock(item, "consume")}><RotateCcw size={16} /></Button>}
|
||||
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Deaktivieren" onClick={() => { setDeleteItem(item); setDeleteError(""); }}><Trash2 size={16} /></Button>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function openStock(item: InventoryItem, action: StockAction) {
|
||||
setSelectedItem(item);
|
||||
setStockAction(action);
|
||||
setStockError("");
|
||||
setStockOpen(true);
|
||||
}
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
|
||||
async function saveItem(payload: InventoryItemPayload) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selectedItem) {
|
||||
await api.put<InventoryItem>(`/inventory/items/${selectedItem.id}`, payload);
|
||||
showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku });
|
||||
} else {
|
||||
const response = await api.post<InventoryItem>("/inventory/items", payload);
|
||||
showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku });
|
||||
}
|
||||
setFormOpen(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStock(payload: InventoryStockPayload) {
|
||||
if (!selectedItem) return;
|
||||
setPending(true);
|
||||
setStockError("");
|
||||
try {
|
||||
await api.post<InventoryItem>(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload);
|
||||
showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku });
|
||||
setStockOpen(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setStockError(message);
|
||||
showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteItem) return;
|
||||
setPending(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await api.delete(`/inventory/items/${deleteItem.id}`);
|
||||
showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku });
|
||||
setDeleteItem(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Lager / Ersatzteile</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{total} Artikel im aktuellen Filter</p>
|
||||
</div>
|
||||
{canCreate && <Button type="button" onClick={() => { setSelectedItem(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Artikel anlegen</Button>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["items", "categories", "locations", "suppliers"] as const).map((value) => (
|
||||
<Button key={value} type="button" variant={tab === value ? "default" : "outline"} onClick={() => setTab(value)}>
|
||||
{{ items: "Artikel", categories: "Kategorien", locations: "Lagerorte", suppliers: "Lieferanten" }[value]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "items" ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
|
||||
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Artikel suchen" />
|
||||
<select value={categoryFilter} onChange={(event) => { setCategoryFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||
</select>
|
||||
<select value={locationFilter} onChange={(event) => { setLocationFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||
<option value="all">Alle Lagerorte</option>
|
||||
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||
</select>
|
||||
<select value={supplierFilter} onChange={(event) => { setSupplierFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||
<option value="all">Alle Lieferanten</option>
|
||||
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||
</select>
|
||||
<select value={stockFilter} onChange={(event) => { setStockFilter(event.target.value as "all" | "low"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||
<option value="all">Alle Bestände</option>
|
||||
<option value="low">Niedriger Bestand</option>
|
||||
</select>
|
||||
<select value={activeFilter} onChange={(event) => { setActiveFilter(event.target.value as "true" | "false" | "all"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||
<option value="true">Aktiv</option>
|
||||
<option value="false">Inaktiv</option>
|
||||
<option value="all">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!loading && !error && total === 0 && !search ? (
|
||||
<EmptyState
|
||||
title="Noch keine Ersatzteile vorhanden."
|
||||
description="Lege den ersten Artikel an, um Bestand, Preise und Lieferanten zu pflegen."
|
||||
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Artikel anlegen</Button> : undefined}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sortedItems}
|
||||
rowKey={(item) => item.id}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="Keine Artikel gefunden."
|
||||
emptyDescription="Passe Suche oder Filter an."
|
||||
onSort={handleSort}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Seite {currentPage} von {pageCount}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{tab === "categories" && <MasterDataSection title="Kategorien" kind="categories" entries={categories} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||
{tab === "locations" && <MasterDataSection title="Lagerorte" kind="locations" entries={locations} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||
{tab === "suppliers" && <MasterDataSection title="Lieferanten" kind="suppliers" entries={suppliers} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InventoryItemDialog key={`item-${selectedItem?.id ?? "new"}-${formOpen}`} open={formOpen} item={selectedItem} categories={categories} locations={locations} suppliers={suppliers} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveItem} />
|
||||
<StockActionDialog key={`stock-${selectedItem?.id ?? "none"}-${stockAction}-${stockOpen}`} open={stockOpen} item={selectedItem} action={stockAction} pending={pending} serverError={stockError} onOpenChange={setStockOpen} onSubmit={saveStock} />
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteItem)}
|
||||
title="Artikel deaktivieren"
|
||||
description="Artikel mit Bewegungen werden nicht hart gelöscht, sondern deaktiviert."
|
||||
confirmLabel="Deaktivieren"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => !open && setDeleteItem(null)}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p>{deleteItem?.sku} · {deleteItem?.name}</p>
|
||||
{deleteError && <p className="text-sm text-red-600">{deleteError}</p>}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ const menu = [
|
|||
icon: Package,
|
||||
name: "Lager",
|
||||
href: "/inventory",
|
||||
permission: "inventory.read",
|
||||
},
|
||||
{
|
||||
icon: ShoppingCart,
|
||||
|
|
|
|||
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type {
|
||||
InventoryCategory,
|
||||
InventoryItem,
|
||||
InventoryItemPayload,
|
||||
InventoryLocation,
|
||||
InventorySupplier,
|
||||
} from "@/types/inventory";
|
||||
import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
item: InventoryItem | null;
|
||||
categories: InventoryCategory[];
|
||||
locations: InventoryLocation[];
|
||||
suppliers: InventorySupplier[];
|
||||
pending: boolean;
|
||||
serverError: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: InventoryItemPayload) => void;
|
||||
};
|
||||
|
||||
function payloadFromItem(item: InventoryItem | null): InventoryItemPayload {
|
||||
if (!item) {
|
||||
return emptyItemPayload;
|
||||
}
|
||||
return {
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
category_id: item.category_id,
|
||||
manufacturer: item.manufacturer,
|
||||
manufacturer_part_number: item.manufacturer_part_number,
|
||||
supplier_id: item.supplier_id,
|
||||
supplier_part_number: item.supplier_part_number,
|
||||
compatible_devices: item.compatible_devices,
|
||||
location_id: item.location_id,
|
||||
quantity_on_hand: item.quantity_on_hand,
|
||||
quantity_reserved: item.quantity_reserved,
|
||||
reorder_level: item.reorder_level,
|
||||
unit: item.unit,
|
||||
purchase_price_cents: item.purchase_price_cents,
|
||||
selling_price_cents: item.selling_price_cents,
|
||||
currency: item.currency,
|
||||
tax_rate_percent: item.tax_rate_percent,
|
||||
notes: item.notes,
|
||||
is_active: item.is_active,
|
||||
};
|
||||
}
|
||||
|
||||
export default function InventoryItemDialog({
|
||||
open,
|
||||
item,
|
||||
categories,
|
||||
locations,
|
||||
suppliers,
|
||||
pending,
|
||||
serverError,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const initialPayload = payloadFromItem(item);
|
||||
const [payload, setPayload] = useState<InventoryItemPayload>(initialPayload);
|
||||
const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents));
|
||||
const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents));
|
||||
const [clientError, setClientError] = useState("");
|
||||
|
||||
function update<K extends keyof InventoryItemPayload>(key: K, value: InventoryItemPayload[K]) {
|
||||
setPayload((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const purchaseCents = euroToCents(purchasePrice);
|
||||
const sellingCents = euroToCents(sellingPrice);
|
||||
if (!payload.name.trim()) {
|
||||
setClientError("Bitte einen Artikelnamen angeben.");
|
||||
return;
|
||||
}
|
||||
if (payload.quantity_reserved > payload.quantity_on_hand) {
|
||||
setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten.");
|
||||
return;
|
||||
}
|
||||
if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) {
|
||||
setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50.");
|
||||
return;
|
||||
}
|
||||
setClientError("");
|
||||
onSubmit({
|
||||
...payload,
|
||||
sku: payload.sku?.trim() || null,
|
||||
name: payload.name.trim(),
|
||||
purchase_price_cents: purchaseCents,
|
||||
selling_price_cents: sellingCents,
|
||||
description: payload.description?.trim() || null,
|
||||
manufacturer: payload.manufacturer?.trim() || null,
|
||||
manufacturer_part_number: payload.manufacturer_part_number?.trim() || null,
|
||||
supplier_part_number: payload.supplier_part_number?.trim() || null,
|
||||
compatible_devices: payload.compatible_devices?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item ? "Artikel bearbeiten" : "Artikel anlegen"}</DialogTitle>
|
||||
<DialogDescription>Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5">
|
||||
<section className="grid gap-3">
|
||||
<h3 className="font-semibold text-slate-950">Stammdaten</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm">SKU optional<Input value={payload.sku ?? ""} onChange={(event) => update("sku", event.target.value || null)} placeholder="ET-2026-000001" /></label>
|
||||
<label className="grid gap-1 text-sm md:col-span-2">Name<Input value={payload.name} onChange={(event) => update("name", event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-sm md:col-span-3">Beschreibung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h3 className="font-semibold text-slate-950">Zuordnung</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm">Kategorie<select className="h-8 rounded-lg border px-2 text-sm" value={payload.category_id ?? ""} onChange={(event) => update("category_id", event.target.value ? Number(event.target.value) : null)}>
|
||||
<option value="">Keine Kategorie</option>
|
||||
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Lagerort<select className="h-8 rounded-lg border px-2 text-sm" value={payload.location_id ?? ""} onChange={(event) => update("location_id", event.target.value ? Number(event.target.value) : null)}>
|
||||
<option value="">Kein Lagerort</option>
|
||||
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Lieferant<select className="h-8 rounded-lg border px-2 text-sm" value={payload.supplier_id ?? ""} onChange={(event) => update("supplier_id", event.target.value ? Number(event.target.value) : null)}>
|
||||
<option value="">Kein Lieferant</option>
|
||||
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h3 className="font-semibold text-slate-950">Hersteller und Kompatibilität</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.manufacturer ?? ""} onChange={(event) => update("manufacturer", event.target.value || null)} /></label>
|
||||
<label className="grid gap-1 text-sm">Hersteller-Teilenummer<Input value={payload.manufacturer_part_number ?? ""} onChange={(event) => update("manufacturer_part_number", event.target.value || null)} /></label>
|
||||
<label className="grid gap-1 text-sm">Lieferanten-Teilenummer<Input value={payload.supplier_part_number ?? ""} onChange={(event) => update("supplier_part_number", event.target.value || null)} /></label>
|
||||
<label className="grid gap-1 text-sm md:col-span-3">Kompatible Geräte<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.compatible_devices ?? ""} onChange={(event) => update("compatible_devices", event.target.value || null)} /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h3 className="font-semibold text-slate-950">Bestand und Preise</h3>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<label className="grid gap-1 text-sm">Bestand<Input type="number" min={0} value={payload.quantity_on_hand} onChange={(event) => update("quantity_on_hand", Number(event.target.value))} /></label>
|
||||
<label className="grid gap-1 text-sm">Reserviert<Input type="number" min={0} value={payload.quantity_reserved} onChange={(event) => update("quantity_reserved", Number(event.target.value))} /></label>
|
||||
<label className="grid gap-1 text-sm">Mindestbestand<Input type="number" min={0} value={payload.reorder_level} onChange={(event) => update("reorder_level", Number(event.target.value))} /></label>
|
||||
<label className="grid gap-1 text-sm">Einheit<Input value={payload.unit} onChange={(event) => update("unit", event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-sm">Einkaufspreis €<Input inputMode="decimal" value={purchasePrice} onChange={(event) => setPurchasePrice(event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-sm">Verkaufspreis €<Input inputMode="decimal" value={sellingPrice} onChange={(event) => setSellingPrice(event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-sm">Währung<Input value={payload.currency} onChange={(event) => update("currency", event.target.value.toUpperCase())} /></label>
|
||||
<label className="grid gap-1 text-sm">MwSt. %<Input inputMode="decimal" value={payload.tax_rate_percent} onChange={(event) => update("tax_rate_percent", event.target.value.replace(",", "."))} /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h3 className="font-semibold text-slate-950">Notizen</h3>
|
||||
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} />
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.is_active} onChange={(event) => update("is_active", event.target.checked)} /> Artikel ist aktiv</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Edit, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InventoryCategory, InventoryLocation, InventorySupplier } from "@/types/inventory";
|
||||
import { getErrorMessage } from "./inventory-utils";
|
||||
|
||||
type MasterKind = "categories" | "locations" | "suppliers";
|
||||
type MasterEntry = InventoryCategory | InventoryLocation | InventorySupplier;
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
kind: MasterKind;
|
||||
entries: MasterEntry[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
export default function MasterDataSection({ title, kind, entries, canManage, onChanged }: Props) {
|
||||
const { showToast } = useToast();
|
||||
const [editing, setEditing] = useState<MasterEntry | null>(null);
|
||||
const [deleteEntry, setDeleteEntry] = useState<MasterEntry | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function startCreate() {
|
||||
setEditing({ id: 0, name: "", description: null, created_at: "", updated_at: "" } as MasterEntry);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function startEdit(entry: MasterEntry) {
|
||||
setEditing(entry);
|
||||
setName(entry.name);
|
||||
setDescription("description" in entry && entry.description ? entry.description : "");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Bitte einen Namen angeben.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
const payload = kind === "suppliers"
|
||||
? { name: name.trim(), notes: description.trim() || null }
|
||||
: { name: name.trim(), description: description.trim() || null };
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.put(`/inventory/${kind}/${editing.id}`, payload);
|
||||
} else {
|
||||
await api.post(`/inventory/${kind}`, payload);
|
||||
}
|
||||
showToast({ type: "success", title: `${title} gespeichert` });
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showToast({ type: "error", title: `${title} konnte nicht gespeichert werden`, description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteEntry) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await api.delete(`/inventory/${kind}/${deleteEntry.id}`);
|
||||
showToast({ type: "success", title: `${title} gelöscht`, description: deleteEntry.name });
|
||||
setDeleteEntry(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
showToast({ type: "error", title: `${title} konnte nicht gelöscht werden`, description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-slate-950">{title}</h2>
|
||||
{canManage && <Button type="button" size="sm" onClick={startCreate}><Plus size={14} />Neu</Button>}
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-4 text-sm text-slate-500">Noch keine Einträge vorhanden.</p>
|
||||
) : entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between gap-3 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{entry.name}</p>
|
||||
{"description" in entry && entry.description && <p className="text-sm text-slate-500">{entry.description}</p>}
|
||||
{"notes" in entry && entry.notes && <p className="text-sm text-slate-500">{entry.notes}</p>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => startEdit(entry)} title="Bearbeiten"><Edit size={16} /></Button>
|
||||
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteEntry(entry)} title="Löschen"><Trash2 size={16} /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="mt-4 grid gap-3 rounded-lg border bg-slate-50 p-4">
|
||||
<Input value={name} placeholder="Name" onChange={(event) => setName(event.target.value)} />
|
||||
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={description} placeholder={kind === "suppliers" ? "Notizen" : "Beschreibung"} onChange={(event) => setDescription(event.target.value)} />
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={pending}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteEntry)}
|
||||
title={`${title} löschen`}
|
||||
description="Der Eintrag wird entfernt. Verknüpfte Artikel behalten ihre sonstigen Daten."
|
||||
pending={pending}
|
||||
onOpenChange={(open) => !open && setDeleteEntry(null)}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{deleteEntry?.name}
|
||||
</ConfirmDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { InventoryItem, InventoryStockPayload } from "@/types/inventory";
|
||||
|
||||
export type StockAction = "adjust" | "reserve" | "release" | "consume";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
item: InventoryItem | null;
|
||||
action: StockAction;
|
||||
pending: boolean;
|
||||
serverError: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: InventoryStockPayload) => void;
|
||||
};
|
||||
|
||||
const labels: Record<StockAction, { title: string; description: string; reason: string }> = {
|
||||
adjust: {
|
||||
title: "Bestand anpassen",
|
||||
description: "Setzt den physischen Bestand auf den angegebenen Wert.",
|
||||
reason: "Inventurkorrektur",
|
||||
},
|
||||
reserve: {
|
||||
title: "Bestand reservieren",
|
||||
description: "Reserviert verfügbaren Bestand für einen späteren Vorgang.",
|
||||
reason: "Reservierung",
|
||||
},
|
||||
release: {
|
||||
title: "Reservierung aufheben",
|
||||
description: "Gibt reservierten Bestand wieder frei.",
|
||||
reason: "Reservierung aufgehoben",
|
||||
},
|
||||
consume: {
|
||||
title: "Verbrauch buchen",
|
||||
description: "Bucht Bestand als verbraucht aus.",
|
||||
reason: "Verbrauch",
|
||||
},
|
||||
};
|
||||
|
||||
export default function StockActionDialog({ open, item, action, pending, serverError, onOpenChange, onSubmit }: Props) {
|
||||
const [quantity, setQuantity] = useState(action === "adjust" ? item?.quantity_on_hand ?? 0 : 1);
|
||||
const [reason, setReason] = useState(labels[action].reason);
|
||||
const [note, setNote] = useState("");
|
||||
const [clientError, setClientError] = useState("");
|
||||
|
||||
function submit() {
|
||||
if (quantity <= 0 && action !== "adjust") {
|
||||
setClientError("Bitte eine Menge größer 0 angeben.");
|
||||
return;
|
||||
}
|
||||
if (quantity < 0) {
|
||||
setClientError("Bitte keine negative Menge eingeben.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setClientError("Bitte einen Grund angeben.");
|
||||
return;
|
||||
}
|
||||
setClientError("");
|
||||
onSubmit({
|
||||
quantity,
|
||||
reason: reason.trim(),
|
||||
note: note.trim() || null,
|
||||
reference_type: null,
|
||||
reference_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{labels[action].title}</DialogTitle>
|
||||
<DialogDescription>{item ? `${item.sku} · ${item.name}` : labels[action].description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-600">
|
||||
Bestand {item?.quantity_on_hand ?? 0} · Reserviert {item?.quantity_reserved ?? 0} · Verfügbar {item?.quantity_available ?? 0}
|
||||
</p>
|
||||
<label className="grid gap-1 text-sm">Menge<Input type="number" min={action === "adjust" ? 0 : 1} value={quantity} onChange={(event) => setQuantity(Number(event.target.value))} /></label>
|
||||
<label className="grid gap-1 text-sm">Grund<Input value={reason} onChange={(event) => setReason(event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
|
||||
</div>
|
||||
|
||||
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Buchen"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { InventoryItemPayload } from "@/types/inventory";
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
if (typeof error === "object" && error !== null && "response" in error) {
|
||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
||||
}
|
||||
return "Aktion konnte nicht abgeschlossen werden";
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatMoney(cents: number | null, currency = "EUR") {
|
||||
if (cents === null) {
|
||||
return "Keine Angabe";
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
|
||||
}
|
||||
|
||||
export function euroToCents(value: string) {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const amount = Number(normalized);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
export function centsToEuroInput(cents: number | null) {
|
||||
if (cents === null) {
|
||||
return "";
|
||||
}
|
||||
return (cents / 100).toFixed(2).replace(".", ",");
|
||||
}
|
||||
|
||||
export const emptyItemPayload: InventoryItemPayload = {
|
||||
sku: null,
|
||||
name: "",
|
||||
description: null,
|
||||
category_id: null,
|
||||
manufacturer: null,
|
||||
manufacturer_part_number: null,
|
||||
supplier_id: null,
|
||||
supplier_part_number: null,
|
||||
compatible_devices: null,
|
||||
location_id: null,
|
||||
quantity_on_hand: 0,
|
||||
quantity_reserved: 0,
|
||||
reorder_level: 0,
|
||||
unit: "Stk.",
|
||||
purchase_price_cents: null,
|
||||
selling_price_cents: null,
|
||||
currency: "EUR",
|
||||
tax_rate_percent: "19.00",
|
||||
notes: null,
|
||||
is_active: true,
|
||||
};
|
||||
|
|
@ -17,6 +17,7 @@ export interface DashboardSummary {
|
|||
users: MetricCard[];
|
||||
roles: MetricCard[];
|
||||
repairs: MetricCard[];
|
||||
inventory: MetricCard[];
|
||||
system_status: SystemStatusItem[];
|
||||
activities: EmptyWidget;
|
||||
tasks: EmptyWidget;
|
||||
|
|
|
|||
121
frontend/athena/types/inventory.ts
Normal file
121
frontend/athena/types/inventory.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
export type InventoryMovementType =
|
||||
| "initial"
|
||||
| "purchase"
|
||||
| "adjustment"
|
||||
| "reservation"
|
||||
| "release"
|
||||
| "consumption"
|
||||
| "return"
|
||||
| "correction";
|
||||
|
||||
export interface InventoryCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InventoryLocation {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InventorySupplier {
|
||||
id: number;
|
||||
name: string;
|
||||
contact_name: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
customer_number: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
id: number;
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category_id: number | null;
|
||||
manufacturer: string | null;
|
||||
manufacturer_part_number: string | null;
|
||||
supplier_id: number | null;
|
||||
supplier_part_number: string | null;
|
||||
compatible_devices: string | null;
|
||||
location_id: number | null;
|
||||
quantity_on_hand: number;
|
||||
quantity_reserved: number;
|
||||
quantity_available: number;
|
||||
reorder_level: number;
|
||||
unit: string;
|
||||
purchase_price_cents: number | null;
|
||||
selling_price_cents: number | null;
|
||||
currency: string;
|
||||
tax_rate_percent: string;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
category: InventoryCategory | null;
|
||||
supplier: InventorySupplier | null;
|
||||
location: InventoryLocation | null;
|
||||
}
|
||||
|
||||
export interface InventoryItemListResponse {
|
||||
items: InventoryItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface InventoryStockMovement {
|
||||
id: number;
|
||||
item_id: number;
|
||||
movement_type: InventoryMovementType;
|
||||
quantity: number;
|
||||
reason: string;
|
||||
reference_type: string | null;
|
||||
reference_id: number | null;
|
||||
note: string | null;
|
||||
actor_user_id: number | null;
|
||||
actor_username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type InventoryItemPayload = {
|
||||
sku: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category_id: number | null;
|
||||
manufacturer: string | null;
|
||||
manufacturer_part_number: string | null;
|
||||
supplier_id: number | null;
|
||||
supplier_part_number: string | null;
|
||||
compatible_devices: string | null;
|
||||
location_id: number | null;
|
||||
quantity_on_hand: number;
|
||||
quantity_reserved: number;
|
||||
reorder_level: number;
|
||||
unit: string;
|
||||
purchase_price_cents: number | null;
|
||||
selling_price_cents: number | null;
|
||||
currency: string;
|
||||
tax_rate_percent: string;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type InventoryStockPayload = {
|
||||
quantity: number;
|
||||
reason: string;
|
||||
note: string | null;
|
||||
reference_type: string | null;
|
||||
reference_id: number | null;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue