Files
Vorrania/ios/Sources/DashboardCards.swift
T
ScarriffleandClaude Opus 5 f04d80a330 App: die selbst draufgesetzten Posten fehlten in der Einkaufsliste
Die Einkaufsliste besteht aus zwei Haelften: gerechnete Bedarfe (Artikel
und Gruppen unter ihrem Mindestbestand, Bedarfe je Lagerort) und das,
was jemand selbst draufgesetzt hat - eigene Eintraege und die aus einem
Rezept uebernommenen Zutaten.

Die App kannte nur die erste Haelfte. Sie fragte /shopping-list,
/shopping-list/groups und /shopping-list/by-location ab - drei Routen,
die ausschliesslich rechnen. Die gespeicherten Eintraege liegen in
/shopping-list/entries und kommen gebuendelt ueber /shopping-list/all;
danach hat die App nie gefragt. Ergebnis: Man setzt im Web etwas auf die
Liste, nimmt das Telefon mit in den Laden, und es ist nicht da.

Jetzt holt die App die gebuendelte Route - ein Aufruf statt drei - und
zeigt die eigenen Posten in einem eigenen Abschnitt "Selbst
draufgesetzt", mit Herkunft ("fuer Pfannkuchen") und Menge. Antippen
hakt ab: Der Posten verschwindet sofort, geht das Speichern schief,
kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.

Dieselbe Blindstelle hatte die Dashboard-Karte der App; sie meldete
"alle Mindestbestaende sind gedeckt", waehrend Posten auf der Liste
standen. Auch sie nimmt jetzt die gebuendelte Route.

Dazu ein Hinweis im Docstring von GET /shopping-list: Diese Route ist
ein Drittel der Liste, wer "die Liste" will, nimmt /all. Die Aufteilung
hat in zwei Tagen zweimal jemanden glauben lassen, es sei nichts da -
beim zweiten Mal war es die App selbst.

Gegen den laufenden Server geprueft: Mit einem Eintrag "Backpapier"
liefert /shopping-list weiterhin [] und /shopping-list/all den Eintrag
samt art, name, menge_text, source, recipe_name und done - genau die
Felder, die das neue Swift-Modell liest.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind
Klammer- und Anfuehrungsbalance, dass die drei alten Routen niemand mehr
aufruft, und dass die benutzten Hilfsfunktionen (makeRequest, jsonBody,
send) wirklich so heissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:24:58 +02:00

430 lines
16 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.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] = []
/// Selbst draufgesetzte Posten. Ohne sie meldete die Karte, alles sei
/// gedeckt, waehrend auf der Liste fuenf Dinge standen.
@State private var eintraege: [ShoppingEntry] = []
@State private var fehler: String?
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
var body: some View {
Group {
if let fehler {
Text(fehler).font(.caption).foregroundStyle(.red)
} else if items.isEmpty && offene.isEmpty {
Text("Alle Mindestbestände sind gedeckt.")
.font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
// Eigene Eintraege zuerst: Sie stehen da, weil jemand sie
// bewusst draufgesetzt hat.
ForEach(offene.prefix(5)) { eintrag in
HStack {
Text(eintrag.name).font(.callout)
Spacer()
Text(eintrag.mengeText.isEmpty ? "selbst notiert" : eintrag.mengeText)
.font(.caption).foregroundStyle(.orange)
}
}
ForEach(items.prefix(max(0, 5 - offene.count))) { 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 {
// Die gebuendelte Route: Nur sie bringt auch die selbst
// draufgesetzten Posten mit.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
eintraege = alles.entries
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("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text(item.restlaufzeitText)
.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
}
}
}