Einzelstueck-Liste: Web-Seite /items + iOS-Liste (filterbar nach Lagerort)

Neue Seite 'Einzelstuecke' (Web, /items, in der Sidebar) listet alle physischen Exemplare ueber alle Produkte in der DataTable - je Spalte filterbar (Lagerort als Pfad, Produkt, Kategorie, Shop, ...), Lagerort und Shop inline aenderbar. iOS bekommt dieselbe Liste als schlichte, filterbare Ansicht (Kachel unter 'Listen'): Suche + Lagerort-/Kategorie-Filter, Tippen oeffnet das Einzelstueck. Item-Model/ItemOut um Kategorie ergaenzt; neuer APIClient.allItems().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 14:58:03 +02:00
parent 76acf57edf
commit 6b7ada33b3
7 changed files with 244 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import ItemList from "./pages/ItemList";
import ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
@@ -75,6 +76,7 @@ function Sidebar() {
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" />
<NavItem to="/items" icon="tag" label="Einzelstücke" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && (
@@ -138,6 +140,7 @@ export default function App() {
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/products" element={<Protected wide><Products /></Protected>} />
<Route path="/items" element={<Protected wide><ItemList /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />

View File

@@ -157,6 +157,7 @@ export const api = {
// Einzelstücke (Items mit UID/QR)
listItems: (productId) => request(`/products/${productId}/items`),
listAllItems: () => request("/items"),
createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }),
itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`),
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild";
import { locationOptions, locationPathById } from "../locationPath";
/**
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort)
* anders als „Produkte", das nur die Katalog-Typen zeigt. Über die DataTable je
* Spalte filterbar, u.a. nach Lagerort. Lagerort und Shop sind inline änderbar.
*/
export default function ItemList() {
const { isAdmin } = useAuth();
const [items, setItems] = useState([]);
const [locations, setLocations] = useState([]);
const [shops, setShops] = useState([]);
const [error, setError] = useState(null);
async function load() {
try {
const [is, ls, ss] = await Promise.all([
api.listAllItems(), api.listLocations(), api.listShops(),
]);
setItems(is); setLocations(ls); setShops(ss);
} catch (err) { setError(err.message); }
}
useEffect(() => { load(); }, []);
async function patch(it, body) {
try { await api.updateItem(it.id, body); await load(); }
catch (err) { setError(err.message); }
}
const preis = (it) => (it.price_cents != null
? `${(it.price_cents / 100).toFixed(2)} ${it.currency || ""}`.trim() : "");
const columns = [
{ key: "thumb", header: "", fixed: true, width: 56,
render: (it) => <ProduktThumb productId={it.product_id} alt={it.product_name} /> },
{ key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid,
render: (it) => <span className="strong nowrap">{it.uid}</span> },
{ key: "product", header: "Produkt", grow: true, min: 180,
filterText: (it) => it.product_name || "", sortValue: (it) => it.product_name || "",
render: (it) => it.product_name || "" },
{ key: "brand", header: "Marke", width: 140, filterText: (it) => it.product_brand || "",
render: (it) => <span className="muted">{it.product_brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 160, filterText: (it) => it.category_name || "",
render: (it) => <span className="muted">{it.category_name || ""}</span> },
{ key: "location", header: "Lagerort", width: 220,
filterText: (it) => locationPathById(it.location_id, locations),
render: (it) => (isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(it, { location_id: e.target.value === "" ? null : e.target.value })}>
<option value=""> ohne </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
) : (locationPathById(it.location_id, locations) || "")) },
{ key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
render: (it) => (isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> unbekannt </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : (it.shop_name || "")) },
{ key: "acquired", header: "Gekauft am", width: 120, filterText: (it) => it.acquired_on || "",
sortValue: (it) => it.acquired_on || "", render: (it) => it.acquired_on || "" },
{ key: "warranty", header: "Garantie bis", width: 120, filterText: (it) => it.warranty_until || "",
sortValue: (it) => it.warranty_until || "", render: (it) => it.warranty_until || "" },
{ key: "price", header: "Preis", width: 110, align: "num",
sortValue: (it) => it.price_cents ?? -1, filterText: preis,
render: (it) => preis(it) || "" },
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
render: (it) => <span className="muted">{it.note || ""}</span> },
{ key: "open", header: "", fixed: true, width: 84, align: "num",
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
];
return (
<div>
<div className="page-head">
<div>
<h1>Einzelstücke</h1>
<div className="sub">{items.length} Exemplare · nach Lagerort, Produkt, Kategorie u.a. filterbar</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card">
<DataTable id="items" columns={columns} rows={items}
getRowKey={(it) => it.id} empty="Noch keine Einzelstücke." />
</div>
</div>
);
}