import SwiftUI /// 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 View { /// Dezente Umrandung fuer Eingabefelder in einem Formular. /// /// `.textFieldStyle(.roundedBorder)` zeichnet in der dunklen Darstellung /// einen fast schwarzen Kasten, der sich hart von der Zeile abhebt. Eine /// Systemfuellung passt sich Hell und Dunkel an und bleibt zurueckhaltend, /// macht das Feld aber weiterhin als Eingabe erkennbar. func fieldBox(width: CGFloat) -> some View { self .padding(.horizontal, 10) .padding(.vertical, 6) .frame(maxWidth: width) .background(Color(.tertiarySystemFill)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } /// Textzeile mit Beschriftung links und sichtbarem Rahmen. struct LabeledField: View { let label: String @Binding var text: String var keyboard: UIKeyboardType = .default var body: some View { HStack { Text(label) Spacer(minLength: 12) TextField(label, text: $text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) .fieldBox(width: 190) } } } /// Zahleneingabe mit Beschriftung links und sichtbarem Rahmen. /// /// Ein rechtsbuendiges Textfeld ohne Rahmen liest sich wie fester Text - beim /// Testen war nicht erkennbar, dass sich die Menge ueberhaupt aendern laesst. struct QuantityField: View { let label: String @Binding var text: String var suffix: String? var body: some View { HStack { Text(label) Spacer(minLength: 12) TextField(label, text: $text) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .fieldBox(width: 110) if let suffix { Text(suffix) .foregroundStyle(.secondary) .frame(minWidth: 60, alignment: .leading) } } } } extension Date { /// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt. var startOfMonth: Date { let calendar = Calendar.current let parts = calendar.dateComponents([.year, .month], from: self) return calendar.date(from: parts) ?? self } } /// Monat und Jahr auswaehlen. SwiftUI kennt keinen DatePicker ohne Tag, /// deshalb zwei schmale Auswahlfelder nebeneinander. struct MonthYearPicker: View { @Binding var date: Date // Fest deutsch, damit die Monatsnamen auch dann stimmen, wenn das Geraet // auf einer anderen Sprache steht. private var calendar: Calendar { var kalender = Calendar(identifier: .gregorian) kalender.locale = Locale(identifier: "de_DE") return kalender } private var monthNames: [String] { calendar.monthSymbols } private var years: [Int] { let current = calendar.component(.year, from: Date()) // Rueckwaerts fuer bereits abgelaufene Ware, vorwaerts fuer Konserven. return Array((current - 2)...(current + 15)) } private var month: Binding { Binding( get: { calendar.component(.month, from: date) }, set: { date = replacing(month: $0, year: calendar.component(.year, from: date)) } ) } private var year: Binding { Binding( get: { calendar.component(.year, from: date) }, set: { date = replacing(month: calendar.component(.month, from: date), year: $0) } ) } private func replacing(month: Int, year: Int) -> Date { calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? date } var body: some View { HStack { Text("MHD") Spacer() Picker("Monat", selection: month) { ForEach(1...12, id: \.self) { Text(monthNames[$0 - 1]).tag($0) } } .labelsHidden() Picker("Jahr", selection: year) { ForEach(years, id: \.self) { Text(String($0)).tag($0) } } .labelsHidden() } } } /// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD. struct CheckInFormView: View { let product: Product let units: [Unit] let locations: [StorageLocation] var onDone: (String) -> Void @Environment(\.dismiss) private var dismiss struct Line: Identifiable { let id = UUID() var quantity: String = "1" var hasDate: Bool = false var bestBefore: Date = Date() } @State private var lines: [Line] = [Line()] /// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein /// 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: LineRef? @State private var unit: String = "" @State private var locationId: String? @State private var busy = false @State private var error: String? // Lagerort per QR (…/l/) setzen, statt in der Liste zu suchen. @State private var locScanShown = false @State private var locScanPaused = false @State private var locTorchOn = false @State private var locTorchLocked = false @State private var locScanError: String? @State private var locScanHinweis: String? /// Einheiten der passenden Art plus das Gebinde des Artikels. 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 { // Angezeigt wird "Glas"/"Dose", geschickt wird "package". options.append(UnitOption(value: UnitOption.packageValue, label: product.packageLabel ?? "Packung")) } return options } private var hasPackage: Bool { (product.packageSize ?? 0) > 0 } var body: some View { Form { Section { HStack { VStack(alignment: .leading) { Text(product.name).font(.headline) Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)") .font(.caption).foregroundStyle(.secondary) } Spacer() } } Section("Einheit") { Picker("Einheit", selection: $unit) { ForEach(unitOptions) { Text($0.label).tag($0.value) } } } if !locations.isEmpty { Section { Picker("Lagerort", selection: $locationId) { Text("– keiner –").tag(String?.none) ForEach(locations) { location in Text(location.path(in: locations)).tag(String?.some(location.id)) } } Button { locScanError = nil locScanPaused = false locScanShown = true } label: { Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder") } if let locScanHinweis { Label(locScanHinweis, systemImage: "checkmark.circle.fill") .font(.caption).foregroundStyle(.green) } } header: { Text("Lagerort") } footer: { Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.") } } Section("MHD") { Picker("Angabe", selection: $precision) { Text("Tagesdatum").tag("day") Text("nur Monat/Jahr").tag("month") } } // Jede Charge bekommt einen eigenen Abschnitt. Vorher standen alle // Zeilen gestapelt in einer einzigen Formularzeile und wirkten // dadurch gequetscht. ForEach(Array($lines.enumerated()), id: \.element.id) { index, $line in Section { QuantityField(label: "Menge", text: $line.quantity) Toggle("MHD angeben", isOn: $line.hasDate) if line.hasDate { if precision == "month" { MonthYearPicker(date: $line.bestBefore) } else { DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date) } if DateScanView.isSupported { Button { scanLineId = LineRef(id: line.id) } label: { Label("MHD scannen", systemImage: "text.viewfinder") } } } } header: { HStack { Text(lines.count > 1 ? "Charge \(index + 1)" : "Charge") Spacer() if lines.count > 1 { Button(role: .destructive) { lines.removeAll { $0.id == line.id } } label: { Label("Entfernen", systemImage: "trash") .labelStyle(.iconOnly) } .buttonStyle(.borderless) } } } } Section { Button { lines.append(Line()) } label: { Label("Weitere Charge (anderes MHD)", systemImage: "plus") } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } } .disabled(busy) } } .navigationTitle("Einlagern") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .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 == ziel.id }) { lines[index].bestBefore = datum lines[index].hasDate = true } } } } .fullScreenCover(isPresented: $locScanShown) { locationScannerCover } .onAppear { // Gibt es ein Gebinde, ist das die naheliegende Eingabe. unit = hasPackage ? UnitOption.packageValue : product.unitName // Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht. precision = product.datePrecision == "month" ? "month" : "day" } } // MARK: - Lagerort scannen private var locationScannerCover: some View { NavigationStack { ScannerView(onCode: { code in Task { await handleLocationScan(code) } }, isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked) .ignoresSafeArea(edges: .bottom) .overlay(alignment: .bottom) { VStack(spacing: 6) { if let locScanError { Text(locScanError) .font(.callout).foregroundStyle(.white) .padding(.horizontal, 12).padding(.vertical, 8) .background(.red, in: Capsule()) } Text("QR am Regal/Fach vor die Kamera halten") .font(.callout) .padding(10) .background(.ultraThinMaterial, in: Capsule()) } .padding(.bottom, 24) } .navigationTitle("Lagerort scannen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { locScanShown = false } } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $locTorchOn, locked: $locTorchLocked) } } } .onAppear { locScanPaused = false; locScanError = nil } } /// Aus einem gescannten Lagerort-QR (…/l/) den Zielort setzen. private func handleLocationScan(_ code: String) async { guard let r = code.range(of: "/l/") else { locScanError = "Das ist kein Lagerort-QR." return } let ziel = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .uppercased() if let treffer = locations.first(where: { $0.id == ziel }) { locationId = treffer.id locScanHinweis = treffer.name locScanShown = false } else { locScanError = "Dieser Lagerort ist hier nicht bekannt." } } private func format(_ value: Double) -> String { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } private func submit() async { error = nil let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" let payloadLines: [CheckInLine] = lines.compactMap { line in guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")), quantity > 0 else { return nil } // Bei Monatsangabe genuegt der Monatserste - der Server legt daraus // den Monatsletzten und merkt sich die Genauigkeit. let date = precision == "month" ? line.bestBefore.startOfMonth : line.bestBefore return CheckInLine( quantity: quantity, bestBefore: line.hasDate ? formatter.string(from: date) : nil, bestBeforePrecision: precision, locationId: locationId ) } guard !payloadLines.isEmpty else { error = "Bitte mindestens eine Menge angeben." return } busy = true defer { busy = false } do { let response = try await APIClient.shared.checkInBatch( BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines) ) let total = response.productStock / product.articleUnitFactor onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)") dismiss() } catch { self.error = error.localizedDescription } } }