iOS: Einzelstücke (UID/QR) + Scan öffnet das Stück; Seed ohne Modell-Kaufdatum

iOS-Parität zum Web: Item-Modell + API, Einzelstueck-Liste in der Produkt-
Detailansicht (anlegen, je-Stueck Lagerort/Kaufdatum/Garantie/Bezugsquelle/Notiz
aendern, entfernen mit Grund, QR je Stueck via CoreImage). Anlegen-Formular hat
den Schalter "Einzelstuecke". Der Scanner erkennt Einzelstueck-QR (…/i/<UID>) und
oeffnet direkt das Stueck.

Seed: "Kaufdatum"/"Garantie bis" nicht mehr als Modell-Felder auf Elektronik -
diese Angaben gehoeren je Einzelstueck ans Item, nicht ans Modell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 07:48:25 +02:00
parent 6fb5c47de6
commit 7f7129c87a
8 changed files with 421 additions and 9 deletions

View File

@@ -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"}]},

View File

@@ -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)
}

View File

@@ -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/<UID>) 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

266
ios/Sources/ItemViews.swift Normal file
View File

@@ -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 }
}
}

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 = "<group>"; };
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
@@ -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 */,