Listen-Tab: zwei halbe Kacheln "Lebensmittel" und "Gegenstaende" (ProductListView mit fixedType, clientseitig ueber isObject gefiltert wie im Web); Neuanlage uebernimmt den Typ. Einzelstuecke bleiben unveraendert (reine Gegenstands-Welt). Verlauf und "Letzte Bewegungen" auf der Startseite: Tippen auf eine Bewegung oeffnet ueber den neuen Lader ProductByIdView das jeweilige Produkt. Der artikel-eigene Verlauf bleibt ohne Link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
4.6 KiB
Swift
132 lines
4.6 KiB
Swift
import SwiftUI
|
||
|
||
/// Bewegungsverlauf: wer hat wann was ein- und ausgelagert.
|
||
///
|
||
/// Dieselbe Ansicht dient dem Gesamtverlauf und dem Verlauf eines einzelnen
|
||
/// Artikels - der Unterschied ist nur `productId`.
|
||
struct HistoryView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
var productId: Int?
|
||
|
||
@State private var movements: [Movement] = []
|
||
@State private var limit = 100
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
ForEach(tage, id: \.key) { tag in
|
||
Section(display.formatDay(tag.eintraege[0].createdAt)) {
|
||
ForEach(tag.eintraege) { movement in
|
||
// Im Gesamtverlauf führt jede Zeile zum Produkt; im
|
||
// Artikel-eigenen Verlauf ist man schon dort (kein Link).
|
||
if productId == nil {
|
||
NavigationLink {
|
||
ProductByIdView(productId: movement.productId)
|
||
} label: {
|
||
MovementRow(movement: movement, showProduct: true)
|
||
}
|
||
} else {
|
||
MovementRow(movement: movement, showProduct: false)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if movements.isEmpty && !busy {
|
||
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
|
||
}
|
||
|
||
// Nachladen erst anbieten, wenn die aktuelle Menge auch voll ist.
|
||
if movements.count >= limit {
|
||
Button("Mehr laden") {
|
||
limit += 100
|
||
Task { await load() }
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Verlauf")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
}
|
||
|
||
private struct Tag {
|
||
let key: String
|
||
let eintraege: [Movement]
|
||
}
|
||
|
||
/// Nach Tagen gruppiert, neueste zuerst. Die API liefert bereits sortiert,
|
||
/// deshalb reicht es, der Reihe nach zu sammeln.
|
||
private var tage: [Tag] {
|
||
var result: [Tag] = []
|
||
for movement in movements {
|
||
let key = DisplaySettings.dayKey(movement.createdAt)
|
||
if let last = result.last, last.key == key {
|
||
result[result.count - 1] = Tag(key: key, eintraege: last.eintraege + [movement])
|
||
} else {
|
||
result.append(Tag(key: key, eintraege: [movement]))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
movements = try await APIClient.shared.movements(limit: limit, productId: productId)
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MovementRow: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
let movement: Movement
|
||
var showProduct = true
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: movement.isIncoming ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
|
||
.font(.title3)
|
||
.foregroundStyle(movement.isIncoming ? Color.green : Color.orange)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
if showProduct {
|
||
Text(movement.productName)
|
||
}
|
||
Text(menge)
|
||
.font(showProduct ? .caption : .body)
|
||
.foregroundStyle(showProduct ? .secondary : .primary)
|
||
if let note = movement.note, !note.isEmpty {
|
||
Text(note).font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(display.formatTime(movement.createdAt))
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
if let user = movement.username, !user.isEmpty {
|
||
Text(user).font(.caption2).foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Vorzeichen macht die Richtung auch ohne Farbe lesbar.
|
||
private var menge: String {
|
||
let zeichen = movement.isIncoming ? "+" : "−"
|
||
return zeichen + display.amountText(movement.quantity,
|
||
packageSize: movement.packageSize,
|
||
baseUnit: movement.baseUnit)
|
||
}
|
||
}
|