iOS zeigt dieselben Dashboards und kann direkt buchen

Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig
davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben
Dashboards wie die Web-Oberflaeche und stellt sie nativ dar.

Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb
stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst
Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es
mehrere Dashboards, schaltet oben ein Menue um.

Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne
den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach
und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte
Zahl.

Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den
ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt.

Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die
Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln.
OverviewView faellt weg, DashboardView tritt an ihre Stelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 19:27:01 +02:00
parent 89598147ed
commit 2ad197df42
7 changed files with 633 additions and 128 deletions

View File

@@ -0,0 +1,403 @@
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 {
// "article" ist die Einheit, in der der Artikel gefuehrt wird -
// genau die, die in der Karte steht.
if einlagern {
let zeile = CheckInLine(quantity: wert, bestBefore: nil,
bestBeforePrecision: "day", locationId: nil)
_ = try await APIClient.shared.checkInBatch(
BatchCheckInRequest(productId: artikel.id, unit: "article", lines: [zeile]))
} else {
_ = try await APIClient.shared.checkOut(
CheckOutRequest(productId: artikel.id, quantity: wert,
unit: "article", 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
}
}
}