banyaro-ios/BanYaroGo/Views/AusgabenView.swift
rene c03f018c0c Ausgaben: Kategorien backend-konform (kleingeschrieben, sechs Werte)
Backend-Whitelist: tierarzt, futter, zubehoer, versicherung, sitter, sonstiges.
Bisher schickte ich großgeschriebene Display-Strings, daher HTTP 400
'Ungültige Kategorie: Futter'. Jetzt: interner Key kleingeschrieben für die
API, label() für die Anzeige in der Liste und im Picker.
2026-05-30 12:28:04 +02:00

248 lines
8.4 KiB
Swift

import SwiftUI
struct AusgabenView: View {
@State private var expenses: [Expense] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showAdd = false
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateStyle = .medium
return f
}()
private let parseFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f
}()
var body: some View {
content
.navigationTitle("Ausgaben")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAdd = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showAdd) {
AddExpenseSheet { Task { await load() } }
}
.task { await load() }
.refreshable { await load() }
}
@ViewBuilder
private var content: some View {
if isLoading && expenses.isEmpty {
ProgressView()
} else if let errorMessage, expenses.isEmpty {
ContentUnavailableView("Konnte Ausgaben nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
} else if expenses.isEmpty {
ContentUnavailableView("Noch keine Ausgaben", systemImage: "eurosign.circle", description: Text("Tippe oben rechts auf +, um eine Ausgabe hinzuzufügen."))
} else {
List {
Section {
LabeledContent("Gesamt", value: totalLabel)
.font(.headline)
}
Section {
ForEach(expenses) { e in
ExpenseRow(expense: e, dateLabel: formatDate(e.datum))
}
}
}
}
}
private var totalLabel: String {
let total = expenses.reduce(0) { $0 + $1.betrag }
return String(format: "%.2f €", total)
}
private func formatDate(_ s: String) -> String {
if let d = parseFormatter.date(from: String(s.prefix(10))) {
return Self.dateFormatter.string(from: d)
}
return s
}
private func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
expenses = try await APIClient.shared.get("/api/expenses")
} catch {
errorMessage = error.localizedDescription
}
}
}
private struct ExpenseRow: View {
let expense: Expense
let dateLabel: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon(for: expense.kategorie))
.foregroundStyle(Color.accentColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(label(for: expense.kategorie)).font(.subheadline.bold())
HStack(spacing: 6) {
Text(dateLabel)
if let dog = expense.dogName {
Text("\(dog)")
}
}
.font(.caption)
.foregroundStyle(.secondary)
if let n = expense.notiz, !n.isEmpty {
Text(n).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
Text(String(format: "%.2f €", expense.betrag))
.font(.subheadline.monospacedDigit().bold())
}
.padding(.vertical, 4)
}
private func icon(for kategorie: String) -> String {
switch kategorie.lowercased() {
case "futter": return "fork.knife"
case "tierarzt": return "stethoscope"
case "zubehoer": return "bag.fill"
case "versicherung": return "shield.lefthalf.filled"
case "sitter": return "person.fill"
default: return "eurosign.circle"
}
}
private func label(for kategorie: String) -> String {
switch kategorie.lowercased() {
case "futter": return "Futter"
case "tierarzt": return "Tierarzt"
case "zubehoer": return "Zubehör"
case "versicherung": return "Versicherung"
case "sitter": return "Sitter"
case "sonstiges": return "Sonstiges"
default: return kategorie.capitalized
}
}
}
private struct AddExpenseSheet: View {
@Environment(\.dismiss) private var dismiss
let onSaved: () -> Void
@State private var kategorie = "futter"
@State private var betrag = ""
@State private var date = Date()
@State private var notiz = ""
@State private var dogs: [Dog] = []
@State private var dogId: Int?
@State private var isSaving = false
@State private var errorMessage: String?
/// Backend-Whitelist: tierarzt, futter, zubehoer, versicherung, sitter, sonstiges
private let kategorien: [(key: String, label: String)] = [
("futter", "Futter"),
("tierarzt", "Tierarzt"),
("zubehoer", "Zubehör"),
("versicherung", "Versicherung"),
("sitter", "Sitter"),
("sonstiges", "Sonstiges")
]
var body: some View {
NavigationStack {
Form {
Section("Kategorie") {
Picker("Kategorie", selection: $kategorie) {
ForEach(kategorien, id: \.key) { entry in
Text(entry.label).tag(entry.key)
}
}
}
Section("Betrag") {
TextField("0,00", text: $betrag)
.keyboardType(.decimalPad)
}
Section("Datum") {
DatePicker("Datum", selection: $date, displayedComponents: .date)
.environment(\.locale, Locale(identifier: "de_DE"))
}
Section("Hund (optional)") {
Picker("Hund", selection: $dogId) {
Text("Ohne Hund").tag(Int?.none)
ForEach(dogs) { dog in
Text(dog.name).tag(Int?.some(dog.id))
}
}
}
Section("Notiz (optional)") {
TextField("Notiz", text: $notiz, axis: .vertical)
.lineLimit(2...4)
}
if let errorMessage {
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
}
}
.navigationTitle("Neue Ausgabe")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }.disabled(isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving { ProgressView() }
else { Button("Sichern") { Task { await save() } }.disabled(!canSave) }
}
}
.task { await loadDogs() }
}
}
private var canSave: Bool {
Double(betrag.replacingOccurrences(of: ",", with: ".")) != nil
}
private func loadDogs() async {
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
}
private func save() async {
isSaving = true
errorMessage = nil
defer { isSaving = false }
guard let betragValue = Double(betrag.replacingOccurrences(of: ",", with: ".")) else {
errorMessage = "Ungültiger Betrag."
return
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let body = ExpenseCreateBody(
dogId: dogId,
kategorie: kategorie,
betrag: betragValue,
datum: formatter.string(from: date),
notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz
)
do {
let _: Expense = try await APIClient.shared.post("/api/expenses", body: body)
onSaved()
dismiss()
} catch {
errorMessage = error.localizedDescription
}
}
}