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:
@@ -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",
|
"name": "Elektronik",
|
||||||
"fields": [
|
"fields": [],
|
||||||
{"label": "Kaufdatum", "type": "date"},
|
|
||||||
{"label": "Garantie bis", "type": "date"},
|
|
||||||
],
|
|
||||||
"children": [
|
"children": [
|
||||||
{"name": "Powerbank",
|
{"name": "Powerbank",
|
||||||
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
|
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
|
||||||
|
|||||||
@@ -152,6 +152,40 @@ actor APIClient {
|
|||||||
try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE"))
|
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] {
|
func groups() async throws -> [GroupItem] {
|
||||||
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct CheckInView: View {
|
|||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@State private var product: Product?
|
@State private var product: Product?
|
||||||
|
@State private var scannedItem: Item?
|
||||||
@State private var suggestion: LookupResult.Suggestion?
|
@State private var suggestion: LookupResult.Suggestion?
|
||||||
@State private var suggestedGroupId: Int?
|
@State private var suggestedGroupId: Int?
|
||||||
@State private var suggestedGroupName: String?
|
@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
|
// MARK: - Bausteine
|
||||||
@@ -201,6 +205,16 @@ struct CheckInView: View {
|
|||||||
suggestion = nil
|
suggestion = nil
|
||||||
unknownCode = nil
|
unknownCode = nil
|
||||||
do {
|
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)
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
suggestedGroupId = result.groupId
|
suggestedGroupId = result.groupId
|
||||||
suggestedGroupName = result.groupName
|
suggestedGroupName = result.groupName
|
||||||
|
|||||||
266
ios/Sources/ItemViews.swift
Normal file
266
ios/Sources/ItemViews.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,9 +74,11 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
let productUrl: String?
|
let productUrl: String?
|
||||||
/// Selbst definierte Feldwerte: {feld_id (als Text): Wert}.
|
/// Selbst definierte Feldwerte: {feld_id (als Text): Wert}.
|
||||||
let fieldValues: [String: String?]?
|
let fieldValues: [String: String?]?
|
||||||
|
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
|
||||||
|
let individual: Bool?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 datePrecision = "date_precision"
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
case categoryName = "category_name"
|
case categoryName = "category_name"
|
||||||
@@ -99,6 +101,8 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
/// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)?
|
/// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)?
|
||||||
var isObject: Bool { tracking == "object" }
|
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).
|
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
||||||
var articleUnitLabel: String {
|
var articleUnitLabel: String {
|
||||||
@@ -264,9 +268,10 @@ struct NewProductRequest: Codable {
|
|||||||
let datePrecision: String
|
let datePrecision: String
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
let categoryId: Int?
|
let categoryId: Int?
|
||||||
|
var individual: Bool? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case barcode, name, brand
|
case barcode, name, brand, individual
|
||||||
case imageUrl = "image_url"
|
case imageUrl = "image_url"
|
||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
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
|
// MARK: - Uebersicht und Verlauf
|
||||||
|
|
||||||
/// Kennzahlen der Startseite (GET /dashboard/stats).
|
/// Kennzahlen der Startseite (GET /dashboard/stats).
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ struct ProductDetailView: View {
|
|||||||
|
|
||||||
ProductPhotoSection(productId: current.id)
|
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,
|
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||||
onChanged: { await reload() })
|
onChanged: { await reload() })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ struct ProductFormView: View {
|
|||||||
@State private var selectedCategoryId: Int?
|
@State private var selectedCategoryId: Int?
|
||||||
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
|
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
|
||||||
@State private var modus = "object"
|
@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 groups: [GroupItem] = []
|
||||||
@State private var categories: [CategoryItem] = []
|
@State private var categories: [CategoryItem] = []
|
||||||
@State private var units: [Unit] = []
|
@State private var units: [Unit] = []
|
||||||
@@ -97,9 +99,14 @@ struct ProductFormView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
|
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
|
||||||
|
if modus == "object" {
|
||||||
|
Toggle("Einzelstücke (UID/QR je Stück)", isOn: $individual)
|
||||||
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(modus == "object"
|
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.")
|
: "Lebensmittel: Chargen mit Mindesthaltbarkeit.")
|
||||||
}
|
}
|
||||||
.onChange(of: modus) { neu in
|
.onChange(of: modus) { neu in
|
||||||
@@ -209,7 +216,8 @@ struct ProductFormView: View {
|
|||||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
datePrecision: datePrecision,
|
datePrecision: datePrecision,
|
||||||
groupId: selectedGroupId,
|
groupId: selectedGroupId,
|
||||||
categoryId: selectedCategoryId
|
categoryId: selectedCategoryId,
|
||||||
|
individual: modus == "object" ? individual : nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onCreated(product)
|
onCreated(product)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
|
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
|
||||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
||||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.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 */; };
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||||
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||||
|
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||||
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
||||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||||
@@ -220,6 +223,7 @@
|
|||||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||||
|
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||||
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
||||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||||
|
|||||||
Reference in New Issue
Block a user