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 } } }