diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index b54d48c..0a43082 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -1,8 +1,9 @@ import SwiftUI -/// Damit sich eine UUID direkt als sheet(item:) verwenden laesst. -extension UUID: Identifiable { - public var id: UUID { self } +/// Verweis auf die Charge, deren MHD gerade gescannt wird. Eigener Typ, damit +/// Foundations UUID nicht nachtraeglich Identifiable zugeschrieben bekommt. +struct LineRef: Identifiable { + let id: UUID } extension Date { @@ -83,25 +84,26 @@ struct CheckInFormView: View { /// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt. @State private var precision: String = "day" /// Charge, fuer die gerade das MHD abgescannt wird. - @State private var scanLineId: UUID? + @State private var scanLineId: LineRef? @State private var unit: String = "" @State private var locationId: Int? @State private var busy = false @State private var error: String? /// Einheiten der passenden Art plus das Gebinde des Artikels. - private var unitOptions: [String] { - var options = units.filter { $0.kind == product.kind }.map(\.name) + private var unitOptions: [UnitOption] { + var options = units + .filter { $0.kind == product.kind } + .map { UnitOption(value: $0.name, label: $0.name) } if let size = product.packageSize, size > 0 { - options.append(product.packageLabel ?? "Packung") + // Angezeigt wird "Glas"/"Dose", geschickt wird "package". + options.append(UnitOption(value: UnitOption.packageValue, + label: product.packageLabel ?? "Packung")) } return options } - private var packageOptionName: String? { - guard let size = product.packageSize, size > 0 else { return nil } - return product.packageLabel ?? "Packung" - } + private var hasPackage: Bool { (product.packageSize ?? 0) > 0 } var body: some View { Form { @@ -118,7 +120,7 @@ struct CheckInFormView: View { Section("Einheit") { Picker("Einheit", selection: $unit) { - ForEach(unitOptions, id: \.self) { Text($0).tag($0) } + ForEach(unitOptions) { Text($0.label).tag($0.value) } } if !locations.isEmpty { Picker("Lagerort", selection: $locationId) { @@ -165,7 +167,7 @@ struct CheckInFormView: View { } if DateScanView.isSupported { Button { - scanLineId = line.id + scanLineId = LineRef(id: line.id) } label: { Label("MHD scannen", systemImage: "text.viewfinder") .font(.callout) @@ -197,13 +199,13 @@ struct CheckInFormView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } - .sheet(item: $scanLineId) { id in + .sheet(item: $scanLineId) { ziel in NavigationStack { DateScanView { datum, erkanntePraezision in // Nennt der Aufdruck keinen Tag, stellt sich die Eingabe // sichtbar auf Monat/Jahr um. if erkanntePraezision == "month" { precision = "month" } - if let index = lines.firstIndex(where: { $0.id == id }) { + if let index = lines.firstIndex(where: { $0.id == ziel.id }) { lines[index].bestBefore = datum lines[index].hasDate = true } @@ -212,7 +214,7 @@ struct CheckInFormView: View { } .onAppear { // Gibt es ein Gebinde, ist das die naheliegende Eingabe. - unit = packageOptionName ?? product.unitName + unit = hasPackage ? UnitOption.packageValue : product.unitName // Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht. precision = product.datePrecision == "month" ? "month" : "day" } diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index 7d9bd50..5e4a4c5 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -127,10 +127,14 @@ struct CheckOutFormView: View { @State private var busy = false @State private var error: String? - private var unitOptions: [String] { - var options = units.filter { $0.kind == product.kind }.map(\.name) + private var unitOptions: [UnitOption] { + var options = units + .filter { $0.kind == product.kind } + .map { UnitOption(value: $0.name, label: $0.name) } if let size = product.packageSize, size > 0 { - options.append(product.packageLabel ?? "Packung") + // Angezeigt wird "Glas"/"Dose", geschickt wird "package". + options.append(UnitOption(value: UnitOption.packageValue, + label: product.packageLabel ?? "Packung")) } return options } @@ -164,7 +168,7 @@ struct CheckOutFormView: View { .frame(maxWidth: 110) } Picker("Einheit", selection: $unit) { - ForEach(unitOptions, id: \.self) { Text($0).tag($0) } + ForEach(unitOptions) { Text($0.label).tag($0.value) } } } @@ -183,7 +187,7 @@ struct CheckOutFormView: View { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .task { - unit = (product.packageSize ?? 0) > 0 ? (product.packageLabel ?? "Packung") : product.unitName + unit = (product.packageSize ?? 0) > 0 ? UnitOption.packageValue : product.unitName lots = (try? await APIClient.shared.lots(productId: product.id)) ?? [] } } diff --git a/ios/Sources/Info.plist b/ios/Sources/Info.plist index fcc10d3..13ef2b9 100644 --- a/ios/Sources/Info.plist +++ b/ios/Sources/Info.plist @@ -25,6 +25,22 @@ UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSCameraUsageDescription Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet. diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift new file mode 100644 index 0000000..9bf92ab --- /dev/null +++ b/ios/Sources/ListViews.swift @@ -0,0 +1,175 @@ +import SwiftUI + +/// Gemeinsame Zahlenformatierung der Listen. +func formatAmount(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) +} + +/// MHD lesbar: Monatsangaben ohne Tag ("09/2026"). +func formatBestBefore(_ raw: String?, precision: String?) -> String { + guard let raw else { return "ohne MHD" } + guard precision == "month" else { return raw } + let teile = raw.split(separator: "-") + guard teile.count >= 2 else { return raw } + return "\(teile[1])/\(teile[0])" +} + +// MARK: - Einkaufsliste + +struct ShoppingListView: View { + @State private var items: [ShoppingItem] = [] + @State private var groups: [GroupShoppingItem] = [] + @State private var busy = true + @State private var error: String? + + var body: some View { + List { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + if !items.isEmpty { + Section("Artikel unter Mindestbestand") { + ForEach(items) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + Text("fehlt \(formatAmount(item.deficit)) · Bestand \(formatAmount(item.stock)) von \(formatAmount(item.minStock))") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + if !groups.isEmpty { + Section("Gruppen unter Mindestbestand") { + ForEach(groups) { group in + VStack(alignment: .leading, spacing: 2) { + Text(group.name) + Text("fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + if items.isEmpty && groups.isEmpty && !busy { + Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.") + .foregroundStyle(.secondary) + } + } + .navigationTitle("Einkaufsliste") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + busy = true + defer { busy = false } + do { + items = try await APIClient.shared.shoppingList() + groups = try await APIClient.shared.shoppingGroups() + error = nil + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - Bald ablaufend + +struct ExpiringView: View { + @State private var items: [ExpiringItem] = [] + @State private var busy = true + @State private var error: String? + + var body: some View { + List { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + ForEach(items) { item in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.productName) + Text("MHD \(formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(restText(item.daysLeft)) + .font(.caption).bold() + .foregroundStyle(item.daysLeft < 0 ? .red : .orange) + } + } + if items.isEmpty && !busy { + Text("Nichts läuft demnächst ab.").foregroundStyle(.secondary) + } + } + .navigationTitle("Bald ablaufend") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + busy = true + defer { busy = false } + do { + items = try await APIClient.shared.expiring() + error = nil + } catch { + self.error = error.localizedDescription + } + } + + /// Gebinde ist die Leitangabe, sonst die Produkteinheit. + private func articleAmount(_ item: ExpiringItem) -> String { + if let size = item.packageSize, size > 0 { + return "\(formatAmount(item.quantity / size)) \(item.packageLabel ?? "Packung")" + } + let faktor = item.unitFactor == 0 ? 1 : item.unitFactor + return "\(formatAmount(item.quantity / faktor)) \(item.unitName)" + } + + private func restText(_ days: Int) -> String { + if days < 0 { return "abgelaufen" } + if days == 0 { return "heute" } + return days == 1 ? "1 Tag" : "\(days) Tage" + } +} + +// MARK: - Produktliste + +struct ProductListView: View { + @State private var query = "" + @State private var products: [Product] = [] + @State private var busy = false + + var body: some View { + List { + ForEach(products) { product in + NavigationLink { + ProductDetailView(product: product) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(product.name) + Text("\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + if products.isEmpty && !busy { + Text("Keine Treffer").foregroundStyle(.secondary) + } + } + .searchable(text: $query, prompt: "Artikel suchen") + .onChange(of: query) { _ in Task { await load() } } + .navigationTitle("Produkte") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + busy = true + defer { busy = false } + products = (try? await APIClient.shared.searchProducts(query)) ?? [] + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 82b2f67..9dee38a 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -83,6 +83,21 @@ struct Product: Codable, Identifiable, Hashable { var stockInArticleUnits: Double { stock / articleUnitFactor } } +/// Auswahleintrag fuer Einheiten. +/// +/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt +/// fuer das Gebinde nur das Schluesselwort "package" (siehe +/// services/conversion.py). Die Bezeichnung "Glas" oder "Dose" ist reine +/// Anzeige - wird sie mitgeschickt, antwortet der Server +/// "Unbekannte Einheit: Glas". +struct UnitOption: Hashable, Identifiable { + let value: String + let label: String + var id: String { value } + + static let packageValue = "package" +} + struct LookupResult: Codable { let found: Bool let existingProduct: Product? diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift new file mode 100644 index 0000000..a33c96d --- /dev/null +++ b/ios/Sources/ProductDetailView.swift @@ -0,0 +1,259 @@ +import SwiftUI + +/// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen. +struct ProductDetailView: View { + let product: Product + + @State private var current: Product + @State private var lots: [Lot] = [] + @State private var busy = false + @State private var error: String? + @State private var status: String? + + // Bearbeitbare Felder + @State private var name = "" + @State private var brand = "" + @State private var packageSize = "" + @State private var packageLabel = "" + @State private var datePrecision = "day" + + @State private var editLot: Lot? + + private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] + + init(product: Product) { + self.product = product + _current = State(initialValue: product) + } + + var body: some View { + Form { + if let status { + Section { Text(status).foregroundStyle(.green).font(.callout) } + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + + Section("Bestand") { + LabeledContent("Vorrat", + value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") + if current.expiredCount > 0 { + LabeledContent("Abgelaufen", value: "\(current.expiredCount)") + .foregroundStyle(.red) + } + } + + Section("Artikel") { + TextField("Name", text: $name) + TextField("Marke", text: $brand) + TextField("Packungsgröße (in \(current.baseUnit))", text: $packageSize) + .keyboardType(.decimalPad) + Picker("Bezeichnung", selection: $packageLabel) { + ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } + } + Picker("MHD-Angabe", selection: $datePrecision) { + Text("Tagesdatum").tag("day") + Text("nur Monat/Jahr").tag("month") + } + } + + Section { + Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } } + .disabled(busy || name.isEmpty) + } + + Section("Chargen") { + ForEach(lots) { lot in + Button { + editLot = lot + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("MHD \(formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + } + .onDelete { indexSet in + Task { await deleteLots(at: indexSet) } + } + if lots.isEmpty { + Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) + } + } + } + .navigationTitle(current.name) + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $editLot) { lot in + NavigationStack { + LotEditView(lot: lot, product: current) { + Task { await reload() } + } + } + } + .task { + fillForm() + await reload() + } + } + + private func fillForm() { + name = current.name + brand = current.brand ?? "" + packageSize = current.packageSize.map { formatAmount($0) } ?? "" + packageLabel = current.packageLabel ?? "" + datePrecision = current.datePrecision == "month" ? "month" : "day" + } + + private func reload() async { + lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] + if let frisch = try? await APIClient.shared.product(id: current.id) { + current = frisch + } + } + + private func save() async { + error = nil + status = nil + busy = true + defer { busy = false } + do { + current = try await APIClient.shared.updateProduct( + id: current.id, + ProductUpdateRequest( + name: name, + brand: brand.isEmpty ? nil : brand, + packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), + packageLabel: packageLabel.isEmpty ? nil : packageLabel, + datePrecision: datePrecision, + minStock: nil + ) + ) + status = "Gespeichert." + } catch { + self.error = error.localizedDescription + } + } + + private func deleteLots(at indexSet: IndexSet) async { + error = nil + for index in indexSet { + do { + try await APIClient.shared.deleteLot(id: lots[index].id) + } catch { + self.error = error.localizedDescription + } + } + await reload() + } +} + +/// Menge und MHD einer Charge korrigieren. +struct LotEditView: View { + let lot: Lot + let product: Product + var onSaved: () -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var quantity = "" + @State private var hasDate = false + @State private var bestBefore = Date() + @State private var precision = "day" + @State private var busy = false + @State private var error: String? + + var body: some View { + Form { + Section("Menge") { + HStack { + Text(product.articleUnitLabel) + Spacer(minLength: 12) + TextField("Menge", text: $quantity) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 110) + } + } + + Section("MHD") { + Toggle("MHD angeben", isOn: $hasDate) + if hasDate { + Picker("Angabe", selection: $precision) { + Text("Tagesdatum").tag("day") + Text("nur Monat/Jahr").tag("month") + } + if precision == "month" { + MonthYearPicker(date: $bestBefore) + } else { + DatePicker("MHD", selection: $bestBefore, displayedComponents: .date) + } + } + } + + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + + Section { + Button(busy ? "Speichern…" : "Speichern") { Task { await save() } } + .disabled(busy) + } + } + .navigationTitle("Charge ändern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + .onAppear(perform: fill) + } + + private func fill() { + quantity = formatAmount(lot.quantity / product.articleUnitFactor) + precision = lot.bestBeforePrecision == "month" ? "month" : "day" + if let raw = lot.bestBefore { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let datum = formatter.date(from: raw) { + bestBefore = datum + hasDate = true + } + } + } + + private func save() async { + error = nil + guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else { + error = "Bitte eine Menge größer 0 angeben." + return + } + busy = true + defer { busy = false } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore + + do { + // Das Backend rechnet Chargenmengen in Basiseinheiten. + _ = try await APIClient.shared.updateLot( + id: lot.id, + LotUpdateRequest( + quantity: menge * product.articleUnitFactor, + bestBefore: hasDate ? formatter.string(from: datum) : nil, + bestBeforePrecision: precision + ) + ) + onSaved() + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index ef74e6e..a98c8f5 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -49,6 +49,24 @@ struct HomeView: View { ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen", systemImage: "arrow.up.to.line") } + NavigationLink { + ShoppingListView() + } label: { + ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt", + systemImage: "cart") + } + NavigationLink { + ExpiringView() + } label: { + ActionTile(title: "Bald ablaufend", subtitle: "Chargen mit knappem MHD", + systemImage: "clock") + } + NavigationLink { + ProductListView() + } label: { + ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten", + systemImage: "list.bullet") + } Spacer() } .padding()