ExpenseCategory DTO + GET /api/expenses/categories beim Öffnen der Liste sowie bei Refresh. Falls der Endpunkt noch nicht ausgerollt ist (oder fehlschlägt), Fallback auf eine lokale Default-Liste mit den aktuellen sechs Kategorien. AddExpenseSheet bekommt die Kategorien als Parameter, statt eigene Liste zu führen — Source of Truth ist jetzt das Backend.
274 lines
9.5 KiB
Swift
274 lines
9.5 KiB
Swift
import SwiftUI
|
|
|
|
struct AusgabenView: View {
|
|
@State private var expenses: [Expense] = []
|
|
@State private var categories: [ExpenseCategory] = []
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var showAdd = false
|
|
|
|
/// Lokale Fallback-Liste, falls der Backend-Endpunkt /api/expenses/categories
|
|
/// noch nicht ausgerollt ist. Sollte im Normalfall überschrieben werden.
|
|
private static let defaultCategories: [ExpenseCategory] = [
|
|
ExpenseCategory(id: "futter", label: "Futter", color: nil),
|
|
ExpenseCategory(id: "tierarzt", label: "Tierarzt", color: nil),
|
|
ExpenseCategory(id: "zubehoer", label: "Zubehör", color: nil),
|
|
ExpenseCategory(id: "versicherung", label: "Versicherung", color: nil),
|
|
ExpenseCategory(id: "sitter", label: "Sitter", color: nil),
|
|
ExpenseCategory(id: "sonstiges", label: "Sonstiges", color: nil)
|
|
]
|
|
|
|
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(categories: categories.isEmpty ? Self.defaultCategories : categories) {
|
|
Task { await load() }
|
|
}
|
|
}
|
|
.task {
|
|
await loadCategories()
|
|
await load()
|
|
}
|
|
.refreshable {
|
|
await loadCategories()
|
|
await load()
|
|
}
|
|
}
|
|
|
|
private func loadCategories() async {
|
|
if let fetched: [ExpenseCategory] = try? await APIClient.shared.get("/api/expenses/categories"),
|
|
!fetched.isEmpty {
|
|
categories = fetched
|
|
} else if categories.isEmpty {
|
|
categories = Self.defaultCategories
|
|
}
|
|
}
|
|
|
|
@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 categories: [ExpenseCategory]
|
|
let onSaved: () -> Void
|
|
|
|
@State private var kategorie: String
|
|
@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?
|
|
|
|
init(categories: [ExpenseCategory], onSaved: @escaping () -> Void) {
|
|
self.categories = categories
|
|
self.onSaved = onSaved
|
|
_kategorie = State(initialValue: categories.first?.id ?? "sonstiges")
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Kategorie") {
|
|
Picker("Kategorie", selection: $kategorie) {
|
|
ForEach(categories) { entry in
|
|
Text(entry.label).tag(entry.id)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|