import SwiftUI // MARK: - Ein- und Auslagern ohne Umweg /// Buchen direkt aus einer Karte heraus: Artikel suchen, Menge, fertig. /// /// MHD und Lagerort bleiben bewusst weg - wer die braucht, ist im Scan-Ablauf /// besser aufgehoben. Hier zaehlt der schnelle Alltagsfall. struct QuickBookingCard: View { enum Richtung { case ein, aus } let richtung: Richtung let onChange: () -> Void @State private var suche = "" @State private var treffer: [Product] = [] @State private var artikel: Product? @State private var menge = "1" @State private var meldung: String? @State private var fehler: String? @State private var laeuft = false private var einlagern: Bool { richtung == .ein } private var zahl: Double? { Double(menge.replacingOccurrences(of: ",", with: ".")) } private var bereit: Bool { artikel != nil && (zahl ?? 0) > 0 && !laeuft } var body: some View { VStack(alignment: .leading, spacing: 10) { if let artikel { HStack { VStack(alignment: .leading, spacing: 2) { Text(artikel.name).font(.subheadline).bold() Text("\(formatAmount(artikel.stockInArticleUnits)) \(artikel.articleUnitLabel) im Bestand") .font(.caption2).foregroundStyle(.secondary) } Spacer() Button { self.artikel = nil suche = "" meldung = nil } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) } } else { TextField("Artikel suchen…", text: $suche) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() .onChange(of: suche) { _ in Task { await suchen() } } ForEach(treffer.prefix(4)) { produkt in Button { artikel = produkt treffer = [] meldung = nil } label: { HStack { Text(produkt.name).font(.callout) Spacer() Image(systemName: "plus.circle").foregroundStyle(.secondary) } } .buttonStyle(.plain) } } HStack(spacing: 10) { TextField("1", text: $menge) .textFieldStyle(.roundedBorder) .keyboardType(.decimalPad) .frame(width: 80) Button { Task { await buchen() } } label: { Label(einlagern ? "Einlagern" : "Auslagern", systemImage: einlagern ? "arrow.down.to.line" : "arrow.up.to.line") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(einlagern ? Color.marke : .orange) .disabled(!bereit) } if let meldung { Text(meldung).font(.caption).foregroundStyle(.green) } if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } } .padding(.vertical, 4) } private func suchen() async { let text = suche.trimmingCharacters(in: .whitespaces) guard text.count >= 2 else { treffer = []; return } treffer = (try? await APIClient.shared.searchProducts(text)) ?? [] } private func buchen() async { guard let artikel, let wert = zahl else { return } laeuft = true defer { laeuft = false } fehler = nil meldung = nil do { // Menge in Artikeleinheiten: Gebinde -> Packung, sonst Basiseinheit. // Der Server kennt kein "article". let unit = (artikel.packageSize ?? 0) > 0 ? "package" : artikel.baseUnit if einlagern { let zeile = CheckInLine(quantity: wert, bestBefore: nil, bestBeforePrecision: "day", locationId: nil) _ = try await APIClient.shared.checkInBatch( BatchCheckInRequest(productId: artikel.id, unit: unit, lines: [zeile])) } else { _ = try await APIClient.shared.checkOut( CheckOutRequest(productId: artikel.id, quantity: wert, unit: unit, lotId: nil)) } meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")." menge = "1" // Bestand in der Karte nachziehen, sonst steht dort die alte Zahl. self.artikel = try? await APIClient.shared.product(id: artikel.id) onChange() } catch { self.fehler = error.localizedDescription } } } // MARK: - Schnellzugriff struct ActionsCard: View { @EnvironmentObject private var router: Router var body: some View { HStack(spacing: 10) { Button { router.route = .checkin } label: { Label("Einlagern", systemImage: "arrow.down.to.line") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { router.route = .checkout } label: { Label("Auslagern", systemImage: "arrow.up.to.line") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } .padding(.vertical, 4) } } // MARK: - Kennzahlen struct StatusCard: View { let art: String let stand: Int @State private var stats: DashboardStats? @State private var fehler: String? var body: some View { Group { if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } else if let stats { switch art { case "kpi-products": Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand", detail: "von \(stats.productsTotal)", tint: .marke) case "kpi-units": Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten", detail: "", tint: .marke) default: HStack(alignment: .top, spacing: 12) { Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend", detail: "", tint: stats.expiringSoon > 0 ? .orange : .secondary) Kennzahl(wert: String(stats.expired), label: "Abgelaufen", detail: "", tint: stats.expired > 0 ? .red : .secondary) Kennzahl(wert: String(stats.shoppingItems), label: "Einzukaufen", detail: "", tint: stats.shoppingItems > 0 ? .orange : .secondary) } } } else { Text("Lädt…").font(.caption).foregroundStyle(.secondary) } } .task(id: stand) { do { stats = try await APIClient.shared.dashboardStats() fehler = nil } catch { self.fehler = error.localizedDescription } } } } struct Kennzahl: View { let wert: String let label: String let detail: String let tint: Color var body: some View { VStack(alignment: .leading, spacing: 2) { Text(label).font(.caption2).foregroundStyle(.secondary) .lineLimit(1).minimumScaleFactor(0.8) Text(wert).font(.title2).bold().foregroundStyle(tint) if !detail.isEmpty { Text(detail).font(.caption2).foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity, alignment: .leading) } } // MARK: - Listen struct ShoppingCard: View { @EnvironmentObject private var display: DisplaySettings let stand: Int @State private var items: [ShoppingItem] = [] @State private var fehler: String? var body: some View { Group { if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } else if items.isEmpty { Text("Alle Mindestbestände sind gedeckt.") .font(.caption).foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 6) { ForEach(items.prefix(5)) { item in HStack { Text(item.name).font(.callout) Spacer() Text("fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))") .font(.caption).foregroundStyle(.orange) } } NavigationLink("Ganze Liste") { ShoppingListView() } .font(.caption) } } } .task(id: stand) { do { items = try await APIClient.shared.shoppingList() fehler = nil } catch { self.fehler = error.localizedDescription } } } } struct ExpiryCard: View { @EnvironmentObject private var display: DisplaySettings let modus: String let stand: Int @State private var items: [ExpiringItem] = [] @State private var fehler: String? /// "expiring" zeigt nur Laufendes, "expired" nur Ueberfaelliges, /// "expiry-all" beides - wie in der Web-Oberflaeche. private var gefiltert: [ExpiringItem] { switch modus { case "expiring": return items.filter { $0.daysLeft >= 0 } case "expired": return items.filter { $0.daysLeft < 0 } default: return items } } var body: some View { Group { if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } else if gefiltert.isEmpty { Text("Nichts läuft demnächst ab.").font(.caption).foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 6) { ForEach(gefiltert.prefix(5)) { item in HStack { VStack(alignment: .leading, spacing: 1) { Text(item.productName).font(.callout) Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))") .font(.caption2).foregroundStyle(.secondary) } Spacer() Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage") .font(.caption).bold() .foregroundStyle(item.daysLeft < 0 ? .red : .orange) } } NavigationLink("Ganze Liste") { ExpiringView() }.font(.caption) } } } .task(id: stand) { do { items = try await APIClient.shared.expiring() fehler = nil } catch { self.fehler = error.localizedDescription } } } } struct MovementsCard: View { let stand: Int @State private var movements: [Movement] = [] @State private var fehler: String? var body: some View { Group { if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } else if movements.isEmpty { Text("Noch keine Bewegungen.").font(.caption).foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { ForEach(movements.prefix(5)) { movement in // Tippen auf eine Bewegung öffnet das jeweilige Produkt. NavigationLink { ProductByIdView(productId: movement.productId) } label: { MovementRow(movement: movement) } .buttonStyle(.plain) } NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption) } } } .task(id: stand) { do { movements = try await APIClient.shared.movements(limit: 5) fehler = nil } catch { self.fehler = error.localizedDescription } } } } // MARK: - Ein fester Artikel struct ProductCard: View { let productId: Int? let stand: Int @State private var produkt: Product? @State private var fehler: String? var body: some View { Group { if productId == nil { Text("Kein Artikel gewählt – einstellen in der Web-Oberfläche.") .font(.caption).foregroundStyle(.secondary) } else if let fehler { Text(fehler).font(.caption).foregroundStyle(.red) } else if let produkt { NavigationLink { ProductDetailView(product: produkt) } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(produkt.name).font(.callout) if let hinweis = mindestbestand(produkt) { Text(hinweis).font(.caption2).foregroundStyle(.orange) } } Spacer() Text("\(formatAmount(produkt.stockInArticleUnits)) \(produkt.articleUnitLabel)") .font(.headline) } } } else { Text("Lädt…").font(.caption).foregroundStyle(.secondary) } } .task(id: "\(productId ?? 0)-\(stand)") { guard let productId else { return } do { produkt = try await APIClient.shared.product(id: productId) fehler = nil } catch { self.fehler = error.localizedDescription } } } private func mindestbestand(_ produkt: Product) -> String? { switch produkt.stockLevel { case .below: return "unter Mindestbestand" case .close: return "bald nachkaufen" case .ok, .none: return nil } } }