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