diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index ccec91b..6bbfab4 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -170,6 +170,11 @@ actor APIClient { 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] { var request = try makeRequest("/products/\(productId)/items", method: "POST") try jsonBody(&request, payload) diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift new file mode 100644 index 0000000..7e77020 --- /dev/null +++ b/ios/Sources/ItemListView.swift @@ -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() + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index bdf4839..67d821d 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -752,6 +752,8 @@ struct Item: Codable, Identifiable, Hashable { let createdAt: String let productName: String? let productBrand: String? + let categoryId: Int? + let categoryName: String? let documents: [ItemDocument] enum CodingKeys: String, CodingKey { @@ -767,6 +769,8 @@ struct Item: Codable, Identifiable, Hashable { case createdAt = "created_at" case productName = "product_name" case productBrand = "product_brand" + case categoryId = "category_id" + case categoryName = "category_name" } } diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index ec919f5..3c1a458 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -175,6 +175,12 @@ struct ListsTabView: View { ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten", systemImage: "shippingbox") } + NavigationLink { + ItemListView() + } label: { + ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort", + systemImage: "tag") + } NavigationLink { ShoppingListView() } label: { diff --git a/web/src/App.jsx b/web/src/App.jsx index c3b8805..4f86d71 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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() { + {isAdmin && ( @@ -138,6 +140,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 6f9e43b..c9b3dce 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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 }), diff --git a/web/src/pages/ItemList.jsx b/web/src/pages/ItemList.jsx new file mode 100644 index 0000000..0faa672 --- /dev/null +++ b/web/src/pages/ItemList.jsx @@ -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) => }, + { key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid, + render: (it) => {it.uid} }, + { 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) => {it.product_brand || "–"} }, + { key: "category", header: "Kategorie", width: 160, filterText: (it) => it.category_name || "", + render: (it) => {it.category_name || "–"} }, + { key: "location", header: "Lagerort", width: 220, + filterText: (it) => locationPathById(it.location_id, locations), + render: (it) => (isAdmin ? ( + + ) : (locationPathById(it.location_id, locations) || "–")) }, + { key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "", + render: (it) => (isAdmin ? ( + + ) : (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) => {it.note || "–"} }, + { key: "open", header: "", fixed: true, width: 84, align: "num", + render: (it) => Produkt }, + ]; + + return ( +
+
+
+

Einzelstücke

+
{items.length} Exemplare · nach Lagerort, Produkt, Kategorie u.a. filterbar
+
+
+ {error &&
{error}
} +
+ it.id} empty="Noch keine Einzelstücke." /> +
+
+ ); +}