Files
Vorrania/ios/Sources/DashboardCards.swift
Scarriffle 2ad197df42 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>
2026-07-23 19:27:01 +02:00

404 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}