diff --git a/backend/app/seed.py b/backend/app/seed.py index 1a03fc5..c1cb8d0 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -116,11 +116,10 @@ OBJECT_EXAMPLES: list[dict] = [ ], }, { + # Kaufdatum/Garantie sind je Einzelstück (Item), nicht am Modell – deshalb + # hier bewusst keine solchen Felder. Modell-Felder = gemeinsame Merkmale. "name": "Elektronik", - "fields": [ - {"label": "Kaufdatum", "type": "date"}, - {"label": "Garantie bis", "type": "date"}, - ], + "fields": [], "children": [ {"name": "Powerbank", "fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]}, diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index bfd31c8..fda29cb 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -152,6 +152,40 @@ actor APIClient { try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE")) } + // MARK: - Einzelstücke (Items mit UID/QR) + + func items(productId: Int) async throws -> [Item] { + try await send(try makeRequest("/products/\(productId)/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) + return try await send(request, as: [Item].self) + } + + /// QR-Auflösung: UID → Einzelstück. + func itemByUid(uid: String) async throws -> Item { + let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid + return try await send(try makeRequest("/items/by-uid/\(escaped)"), as: Item.self) + } + + func updateItem(id: Int, _ payload: ItemUpdateRequest) async throws -> Item { + var request = try makeRequest("/items/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: Item.self) + } + + func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws { + var request = try makeRequest("/items/\(id)/remove", method: "POST") + try jsonBody(&request, payload) + try await sendNoContent(request) + } + + func deleteItem(id: Int) async throws { + try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) + } + func groups() async throws -> [GroupItem] { try await send(try makeRequest("/groups"), as: [GroupItem].self) } diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 49a555f..0b0b678 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -9,6 +9,7 @@ struct CheckInView: View { @State private var error: String? @State private var product: Product? + @State private var scannedItem: Item? @State private var suggestion: LookupResult.Suggestion? @State private var suggestedGroupId: Int? @State private var suggestedGroupName: String? @@ -98,6 +99,9 @@ struct CheckInView: View { } } } + .sheet(item: $scannedItem, onDismiss: { paused = false }) { it in + NavigationStack { ItemEditView(item: it) } + } } // MARK: - Bausteine @@ -201,6 +205,16 @@ struct CheckInView: View { suggestion = nil unknownCode = nil do { + // Einzelstück-QR (…/i/) → direkt das Stück öffnen statt Barcode-Suche. + if let r = code.range(of: "/i/") { + let uid = String(code[r.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) + .uppercased() + if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { + scannedItem = item + return + } + } let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId suggestedGroupName = result.groupName diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift new file mode 100644 index 0000000..27032c8 --- /dev/null +++ b/ios/Sources/ItemViews.swift @@ -0,0 +1,266 @@ +import SwiftUI +import CoreImage + +/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit +/// ein Scan – auch mit der Systemkamera – die App/Weboberfläche öffnet. +enum QRImage { + static func make(_ text: String, scale: CGFloat = 8) -> UIImage? { + guard let data = text.data(using: .utf8), + let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let ci = filter.outputImage? + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil } + let context = CIContext() + guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil } + return UIImage(cgImage: cg) + } +} + +func qrLink(forUid uid: String) -> String { + let base = Session.shared.baseURL?.absoluteString + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + return base.isEmpty ? uid : "\(base)/i/\(uid)" +} + +private func dateToString(_ d: Date) -> String { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d) +} +private func stringToDate(_ s: String) -> Date? { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s) +} + +// MARK: - Liste (in der Produkt-Detailansicht) + +/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die +/// Detail-/Bearbeiten-Ansicht. +struct ProductItemsSection: View { + let product: Product + var onChanged: () async -> Void + + @State private var items: [Item] = [] + @State private var showAdd = false + + var body: some View { + Section("Einzelstücke (\(items.count))") { + ForEach(items) { it in + NavigationLink { + ItemEditView(item: it) { Task { await refresh() } } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(it.uid).font(.body.monospaced()).bold() + Text(untertitel(it)).font(.caption).foregroundStyle(.secondary) + } + } + } + if items.isEmpty { + Text("Noch keine Einzelstücke.").foregroundStyle(.secondary) + } + Button { showAdd = true } label: { + Label("Einzelstücke anlegen", systemImage: "plus") + } + } + .task(id: product.id) { await reload() } + .sheet(isPresented: $showAdd) { + NavigationStack { + ItemAddSheet(productId: product.id) { await refresh() } + } + } + } + + private func untertitel(_ it: Item) -> String { + [it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName] + .compactMap { $0 }.joined(separator: " · ") + } + private func reload() async { + items = (try? await APIClient.shared.items(productId: product.id)) ?? [] + } + private func refresh() async { await reload(); await onChanged() } +} + +// MARK: - Detail / Bearbeiten (auch Ziel eines Scans) + +struct ItemEditView: View { + let item: Item + var onChanged: (() -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + + @State private var locations: [StorageLocation] = [] + @State private var shops: [ShopItem] = [] + @State private var locationId: Int? + @State private var shopId: Int? + @State private var hasAcquired = false + @State private var acquired = Date() + @State private var hasWarranty = false + @State private var warranty = Date() + @State private var note = "" + @State private var busy = false + @State private var error: String? + @State private var showRemove = false + @State private var reason = "broken" + @State private var removeNote = "" + + var body: some View { + Form { + Section { + if let img = QRImage.make(qrLink(forUid: item.uid)) { + Image(uiImage: img) + .interpolation(.none).resizable().scaledToFit() + .frame(maxWidth: .infinity).frame(height: 180) + } + LabeledContent("UID", value: item.uid) + if let name = item.productName { + LabeledContent("Artikel", value: name) + } + } + + Section { + Picker("Lagerort", selection: $locationId) { + Text("– ohne –").tag(Int?.none) + ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + } + Picker("Gekauft bei", selection: $shopId) { + Text("– unbekannt –").tag(Int?.none) + ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } + } + Toggle("Kaufdatum", isOn: $hasAcquired) + if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } + Toggle("Garantie", isOn: $hasWarranty) + if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } + LabeledField(label: "Notiz", text: $note) + } + + if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } + + Section { + Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy) + Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true } + } + } + .navigationTitle("Einzelstück") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } + .task { await load() } + .sheet(isPresented: $showRemove) { + NavigationStack { removeSheet } + } + } + + private var removeSheet: some View { + Form { + Picker("Grund", selection: $reason) { + ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) } + } + LabeledField(label: "Notiz", text: $removeNote) + Section { + Button("Entfernen", role: .destructive) { Task { await remove() } } + } + } + .navigationTitle("Entfernen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } } + } + + private func load() async { + locations = (try? await APIClient.shared.locations()) ?? [] + shops = (try? await APIClient.shared.shops()) ?? [] + locationId = item.locationId + shopId = item.shopId + note = item.note ?? "" + if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } + if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } + } + + private func save() async { + busy = true; defer { busy = false }; error = nil + do { + _ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest( + locationId: locationId, shopId: shopId, + acquiredOn: hasAcquired ? dateToString(acquired) : nil, + warrantyUntil: hasWarranty ? dateToString(warranty) : nil, + note: note.isEmpty ? nil : note)) + onChanged?() + dismiss() + } catch { self.error = error.localizedDescription } + } + + private func remove() async { + do { + try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest( + reason: reason, note: removeNote.isEmpty ? nil : removeNote)) + showRemove = false + onChanged?() + dismiss() + } catch { self.error = error.localizedDescription; showRemove = false } + } +} + +// MARK: - Anlegen + +struct ItemAddSheet: View { + let productId: Int + var onDone: () async -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var count = 1 + @State private var locations: [StorageLocation] = [] + @State private var shops: [ShopItem] = [] + @State private var locationId: Int? + @State private var shopId: Int? + @State private var hasAcquired = false + @State private var acquired = Date() + @State private var hasWarranty = false + @State private var warranty = Date() + @State private var note = "" + @State private var busy = false + @State private var error: String? + + var body: some View { + Form { + Section { + Stepper("Anzahl: \(count)", value: $count, in: 1...200) + Picker("Lagerort", selection: $locationId) { + Text("– ohne –").tag(Int?.none) + ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + } + Picker("Gekauft bei", selection: $shopId) { + Text("– unbekannt –").tag(Int?.none) + ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } + } + Toggle("Kaufdatum", isOn: $hasAcquired) + if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } + Toggle("Garantie", isOn: $hasWarranty) + if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } + LabeledField(label: "Notiz", text: $note) + } footer: { + Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.") + } + if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } + Section { + Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy) + } + } + .navigationTitle("Einzelstücke anlegen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } + .task { + locations = (try? await APIClient.shared.locations()) ?? [] + shops = (try? await APIClient.shared.shops()) ?? [] + } + } + + private func create() async { + busy = true; defer { busy = false }; error = nil + do { + _ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest( + count: count, locationId: locationId, shopId: shopId, + acquiredOn: hasAcquired ? dateToString(acquired) : nil, + warrantyUntil: hasWarranty ? dateToString(warranty) : nil, + note: note.isEmpty ? nil : note)) + await onDone() + dismiss() + } catch { self.error = error.localizedDescription } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index dd6a5a3..bc09d0a 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -74,9 +74,11 @@ struct Product: Codable, Identifiable, Hashable { let productUrl: String? /// Selbst definierte Feldwerte: {feld_id (als Text): Wert}. let fieldValues: [String: String?]? + /// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt. + let individual: Bool? enum CodingKeys: String, CodingKey { - case id, barcode, name, brand, stock, kind, barcodes, tracking + case id, barcode, name, brand, stock, kind, barcodes, tracking, individual case datePrecision = "date_precision" case categoryId = "category_id" case categoryName = "category_name" @@ -99,6 +101,8 @@ struct Product: Codable, Identifiable, Hashable { /// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)? var isObject: Bool { tracking == "object" } + /// Gegenstand, der als Einzelstücke (UID/QR) geführt wird? + var isIndividual: Bool { individual == true } /// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit). var articleUnitLabel: String { @@ -264,9 +268,10 @@ struct NewProductRequest: Codable { let datePrecision: String let groupId: Int? let categoryId: Int? + var individual: Bool? = nil enum CodingKeys: String, CodingKey { - case barcode, name, brand + case barcode, name, brand, individual case imageUrl = "image_url" case baseUnit = "base_unit" case packageSize = "package_size" @@ -564,6 +569,86 @@ enum RemovalReasons { } } +// MARK: - Einzelstücke (Items mit UID/QR) + +struct Item: Codable, Identifiable, Hashable { + let id: Int + let uid: String + let productId: Int + let locationId: Int? + let locationName: String? + let shopId: Int? + let shopName: String? + let acquiredOn: String? // "yyyy-MM-dd" + let warrantyUntil: String? + let note: String? + let createdAt: String + let productName: String? + let productBrand: String? + + enum CodingKeys: String, CodingKey { + case id, uid, note + case productId = "product_id" + case locationId = "location_id" + case locationName = "location_name" + case shopId = "shop_id" + case shopName = "shop_name" + case acquiredOn = "acquired_on" + case warrantyUntil = "warranty_until" + case createdAt = "created_at" + case productName = "product_name" + case productBrand = "product_brand" + } +} + +struct ItemCreateRequest: Codable { + let count: Int + let locationId: Int? + let shopId: Int? + let acquiredOn: String? + let warrantyUntil: String? + let note: String? + + enum CodingKeys: String, CodingKey { + case count, note + case locationId = "location_id" + case shopId = "shop_id" + case acquiredOn = "acquired_on" + case warrantyUntil = "warranty_until" + } +} + +struct ItemUpdateRequest: Encodable { + var locationId: Int? + var shopId: Int? + var acquiredOn: String? + var warrantyUntil: String? + var note: String? + + enum CodingKeys: String, CodingKey { + case note + case locationId = "location_id" + case shopId = "shop_id" + case acquiredOn = "acquired_on" + case warrantyUntil = "warranty_until" + } + + // Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt. + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(locationId, forKey: .locationId) + try c.encode(shopId, forKey: .shopId) + try c.encode(acquiredOn, forKey: .acquiredOn) + try c.encode(warrantyUntil, forKey: .warrantyUntil) + try c.encode(note, forKey: .note) + } +} + +struct ItemRemoveRequest: Codable { + let reason: String + let note: String? +} + // MARK: - Uebersicht und Verlauf /// Kennzahlen der Startseite (GET /dashboard/stats). diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index a2ca129..5e9b997 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -51,7 +51,9 @@ struct ProductDetailView: View { ProductPhotoSection(productId: current.id) - if current.isObject { + if current.isObject && current.isIndividual { + ProductItemsSection(product: current, onChanged: { await reload() }) + } else if current.isObject { ObjectStockSection(product: current, locations: locations, lots: lots, onChanged: { await reload() }) } else { diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index f2b4503..17f3f4f 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -72,6 +72,8 @@ struct ProductFormView: View { @State private var selectedCategoryId: Int? // Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl. @State private var modus = "object" + // Gegenstand als Einzelstücke (UID/QR je Stück) führen? + @State private var individual = false @State private var groups: [GroupItem] = [] @State private var categories: [CategoryItem] = [] @State private var units: [Unit] = [] @@ -97,9 +99,14 @@ struct ProductFormView: View { } .pickerStyle(.segmented) CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus) + if modus == "object" { + Toggle("Einzelstücke (UID/QR je Stück)", isOn: $individual) + } } footer: { Text(modus == "object" - ? "Gegenstand: Menge je Lagerort und eigene Felder." + ? (individual + ? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie." + : "Gegenstand: Menge je Lagerort und eigene Felder.") : "Lebensmittel: Chargen mit Mindesthaltbarkeit.") } .onChange(of: modus) { neu in @@ -209,7 +216,8 @@ struct ProductFormView: View { packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, groupId: selectedGroupId, - categoryId: selectedCategoryId + categoryId: selectedCategoryId, + individual: modus == "object" ? individual : nil ) ) onCreated(product) diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 55ecea1..45f3324 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; }; 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; + 56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; }; 6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; }; 728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; }; 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; }; @@ -58,6 +59,7 @@ 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = ""; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = ""; }; + 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = ""; }; @@ -96,6 +98,7 @@ 717C8EB336170526F5F3E695 /* DateScanView.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, + 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */, A6785CD9174067EFBB2B9043 /* ListViews.swift */, C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */, A75926511854BB8EE316ED3A /* LoginView.swift */, @@ -220,6 +223,7 @@ ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, + 56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */, 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */, 82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */, E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,