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:
@@ -170,6 +170,11 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/products/\(productId)/items"), as: [Item].self)
|
try await send(try makeRequest("/products/\(productId)/items"), as: [Item].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alle Einzelstücke über alle Produkte – für die Einzelstück-Liste.
|
||||||
|
func allItems() async throws -> [Item] {
|
||||||
|
try await send(try makeRequest("/items"), as: [Item].self)
|
||||||
|
}
|
||||||
|
|
||||||
func createItems(productId: Int, _ payload: ItemCreateRequest) async throws -> [Item] {
|
func createItems(productId: Int, _ payload: ItemCreateRequest) async throws -> [Item] {
|
||||||
var request = try makeRequest("/products/\(productId)/items", method: "POST")
|
var request = try makeRequest("/products/\(productId)/items", method: "POST")
|
||||||
try jsonBody(&request, payload)
|
try jsonBody(&request, payload)
|
||||||
|
|||||||
128
ios/Sources/ItemListView.swift
Normal file
128
ios/Sources/ItemListView.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Liste aller Einzelstücke (physische Exemplare) über alle Produkte – anders als
|
||||||
|
/// die Produktliste, die nur die Katalog-Typen zeigt. Such- und Filterfunktion,
|
||||||
|
/// v.a. nach Lagerort. Tippen öffnet das Einzelstück.
|
||||||
|
struct ItemListView: View {
|
||||||
|
@State private var items: [Item] = []
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var locationFilter: String? // Lagerort-ID
|
||||||
|
@State private var categoryFilter: String? // Kategoriename
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private var categories: [String] {
|
||||||
|
Array(Set(items.compactMap { $0.categoryName })).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gefiltert: [Item] {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
return items.filter { it in
|
||||||
|
if let cat = categoryFilter, it.categoryName != cat { return false }
|
||||||
|
if let loc = locationFilter, it.locationId != loc { return false }
|
||||||
|
if !q.isEmpty {
|
||||||
|
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
||||||
|
.compactMap { $0 }.joined(separator: " ").lowercased()
|
||||||
|
if !heu.contains(q) { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if !busy && gefiltert.isEmpty {
|
||||||
|
Text(items.isEmpty ? "Noch keine Einzelstücke." : "Nichts gefunden.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(gefiltert) { it in
|
||||||
|
NavigationLink { ItemEditView(item: it) } label: {
|
||||||
|
ItemRow(item: it, locations: locations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||||
|
.navigationTitle("Einzelstücke")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } }
|
||||||
|
.overlay { if busy && items.isEmpty { ProgressView() } }
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(error ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterMenu: some View {
|
||||||
|
Menu {
|
||||||
|
Menu("Lagerort") {
|
||||||
|
check("Alle", locationFilter == nil) { locationFilter = nil }
|
||||||
|
ForEach(locations) { loc in
|
||||||
|
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !categories.isEmpty {
|
||||||
|
Menu("Kategorie") {
|
||||||
|
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
||||||
|
ForEach(categories, id: \.self) { c in
|
||||||
|
check(c, categoryFilter == c) { categoryFilter = c }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hatFilter {
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: {
|
||||||
|
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: hatFilter
|
||||||
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func check(_ title: String, _ selected: Bool, _ action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
if selected { Label(title, systemImage: "checkmark") } else { Text(title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
items = try await APIClient.shared.allItems()
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Einzelstück-Zeile: Produktbild, Produktname, UID und Lagerort-Pfad.
|
||||||
|
struct ItemRow: View {
|
||||||
|
let item: Item
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProductThumb(productId: item.productId)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.productName ?? "—")
|
||||||
|
let pfad = locationPath(item.locationId, in: locations)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(item.uid).font(.caption).foregroundStyle(.secondary)
|
||||||
|
if !pfad.isEmpty {
|
||||||
|
Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -752,6 +752,8 @@ struct Item: Codable, Identifiable, Hashable {
|
|||||||
let createdAt: String
|
let createdAt: String
|
||||||
let productName: String?
|
let productName: String?
|
||||||
let productBrand: String?
|
let productBrand: String?
|
||||||
|
let categoryId: Int?
|
||||||
|
let categoryName: String?
|
||||||
let documents: [ItemDocument]
|
let documents: [ItemDocument]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -767,6 +769,8 @@ struct Item: Codable, Identifiable, Hashable {
|
|||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case productName = "product_name"
|
case productName = "product_name"
|
||||||
case productBrand = "product_brand"
|
case productBrand = "product_brand"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case categoryName = "category_name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ struct ListsTabView: View {
|
|||||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
||||||
systemImage: "shippingbox")
|
systemImage: "shippingbox")
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ItemListView()
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
||||||
|
systemImage: "tag")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ShoppingListView()
|
ShoppingListView()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import BrandMark from "./components/BrandMark";
|
|||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Products from "./pages/Products";
|
import Products from "./pages/Products";
|
||||||
|
import ItemList from "./pages/ItemList";
|
||||||
import ProductForm from "./pages/ProductForm";
|
import ProductForm from "./pages/ProductForm";
|
||||||
import CheckIn from "./pages/CheckIn";
|
import CheckIn from "./pages/CheckIn";
|
||||||
import CheckOut from "./pages/CheckOut";
|
import CheckOut from "./pages/CheckOut";
|
||||||
@@ -75,6 +76,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||||||
<NavItem to="/products" icon="package" label="Produkte" />
|
<NavItem to="/products" icon="package" label="Produkte" />
|
||||||
|
<NavItem to="/items" icon="tag" label="Einzelstücke" />
|
||||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -138,6 +140,7 @@ export default function App() {
|
|||||||
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||||
<Route path="/products" element={<Protected wide><Products /></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/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const api = {
|
|||||||
|
|
||||||
// Einzelstücke (Items mit UID/QR)
|
// Einzelstücke (Items mit UID/QR)
|
||||||
listItems: (productId) => request(`/products/${productId}/items`),
|
listItems: (productId) => request(`/products/${productId}/items`),
|
||||||
|
listAllItems: () => request("/items"),
|
||||||
createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }),
|
createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }),
|
||||||
itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`),
|
itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`),
|
||||||
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
|
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
|
||||||
|
|||||||
97
web/src/pages/ItemList.jsx
Normal file
97
web/src/pages/ItemList.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user