Die Ein-/Auslagern-Karten schickten unit: "article" - eine Einheit, die der Server nicht kennt (services/units.py akzeptiert nur package/packung bzw. die Basiseinheiten g/ml/stück). Jede Buchung ueber die Karte scheiterte damit an "Unbekannte Einheit: article", in der Web-Oberflaeche wie in der App. Jetzt wird die Menge in Artikeleinheiten gebucht: gibt es ein Gebinde, ist das eine Packung, sonst die Basiseinheit des Produkts. Gegen ein laufendes Backend geprueft (package und Basiseinheit je 200). Aufgefallen beim Testen der Ablauf-Benachrichtigungen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
405 lines
14 KiB
Swift
405 lines
14 KiB
Swift
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.accentColor : .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: .accentColor)
|
||
case "kpi-units":
|
||
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
|
||
detail: "", tint: .accentColor)
|
||
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
|
||
MovementRow(movement: movement)
|
||
}
|
||
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
|
||
}
|
||
}
|
||
}
|