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

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