import SwiftUI /// Soll gegen Ist, Monat für Monat. /// /// Geblättert wird über `prev` und `next` aus der Antwort – welcher Monat auf /// welchen folgt, hängt am eingestellten Budget-Starttag und ist nichts, was /// der Client ausrechnen sollte. struct BudgetAnsicht: View { @Environment(AppZustand.self) private var zustand /// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label. @State private var label: String? @State private var lader: Lader? @State private var bearbeitet: ZeileZumBearbeiten? @State private var vorgang = Speichervorgang() @State private var fragtNachZuruecksetzen = false /// Welche Zeile wohin gesetzt wird – beides zusammen, damit das Blatt /// weiss, ob es den Monat oder die Vorlage ändert. struct ZeileZumBearbeiten: Identifiable, Equatable { let kategorie: Budgetkategorie let ziel: BudgetZeileBearbeiten.Ziel var id: Int { kategorie.categoryId } } var body: some View { Group { if let lader { Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { monat in liste(monat) } } else { ProgressView() } } .navigationTitle(String(localized: "reiter.budget")) .navigationBarTitleDisplayMode(.inline) .task(id: label) { await ladeMonat() } } private func ladeMonat() async { guard let client = zustand.client else { return } let gewaehlt = label let neuer = Lader(schluessel: "budget/\(gewaehlt ?? "current")") { try await client.budget(gewaehlt) } lader = neuer await neuer.lade() } @ViewBuilder private func liste(_ monat: Budgetmonat) -> some View { List { Section { MonatsKopf(monat: monat) } if !monat.income.isEmpty { Section(String(localized: "budget.einnahmen")) { ForEach(monat.income) { posten in EinnahmeZeile(posten: posten, waehrung: monat.baseCurrency) } } } ForEach(monat.groups) { gruppe in Section { ForEach(gruppe.categories) { kategorie in Button { bearbeitet = ZeileZumBearbeiten(kategorie: kategorie, ziel: .monat(monat.label)) } label: { KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency) } .buttonStyle(.plain) .swipeActions(edge: .trailing) { Button { bearbeitet = ZeileZumBearbeiten(kategorie: kategorie, ziel: .vorlage) } label: { Label(String(localized: "budget.vorlage"), systemImage: "doc.text") } .tint(.indigo) } } } header: { GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency) } } if !monat.vertraege.istLeer { Section { VertragsHinweis(reste: monat.vertraege, waehrung: monat.baseCurrency) } } } .listStyle(.insetGrouped) .refreshable { await lader?.aktualisiere() } .toolbar { ToolbarItem(placement: .topBarLeading) { Button { label = monat.prev } label: { Image(systemName: "chevron.left") } .accessibilityLabel(String(localized: "budget.vorigerMonat")) } ToolbarItem(placement: .principal) { Text(monatsname(monat)) .font(.headline) } ToolbarItem(placement: .topBarTrailing) { Button { label = monat.next } label: { Image(systemName: "chevron.right") } .accessibilityLabel(String(localized: "budget.naechsterMonat")) } ToolbarItem(placement: .topBarTrailing) { Menu { Button { Task { await uebernimm { try await $0.holeVorlageInMonat(monat.label) } } } label: { Label(String(localized: "budget.vorlageHolen"), systemImage: "arrow.down.doc") } Button { Task { await uebernimm { try await $0.macheMonatZurVorlage(monat.label) } } } label: { Label(String(localized: "budget.alsVorlageSetzen"), systemImage: "arrow.up.doc") } if monat.customized { Button(role: .destructive) { fragtNachZuruecksetzen = true } label: { Label(String(localized: "budget.zuruecksetzen"), systemImage: "arrow.uturn.backward") } } } label: { Image(systemName: "ellipsis.circle") } .disabled(vorgang.laeuft) } } .sheet(item: $bearbeitet) { eintrag in BudgetZeileBearbeiten(ziel: eintrag.ziel, kategorie: eintrag.kategorie, waehrung: monat.baseCurrency) { Task { await lader?.aktualisiere() } } .environment(zustand) } .confirmationDialog(String(localized: "budget.zuruecksetzenFrage"), isPresented: $fragtNachZuruecksetzen, titleVisibility: .visible) { Button(String(localized: "budget.zuruecksetzen"), role: .destructive) { Task { await uebernimm { try await $0.setzeBudgetMonatZurueck(monat.label) } } } Button(String(localized: "allgemein.abbrechen"), role: .cancel) {} } message: { Text(String(localized: "budget.zuruecksetzenErklaerung")) } .alert(String(localized: "fehler.titel"), isPresented: .constant(vorgang.fehler != nil)) { Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil } } message: { Text(vorgang.fehler ?? "") } } /// Die drei Knöpfe im Menü tun dasselbe: etwas schreiben, dann neu laden. private func uebernimm(_ arbeit: @escaping (CertainClient) async throws -> Void) async { guard let client = zustand.client else { return } if await vorgang.fuehreAus({ try await arbeit(client) }) { await lader?.aktualisiere() } } /// „Oktober 2026" statt „2026-10". private func monatsname(_ monat: Budgetmonat) -> String { monat.start.datum.formatted(.dateTime.month(.wide).year()) } } // MARK: - Bausteine private struct MonatsKopf: View { let monat: Budgetmonat var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text(Geldbetrag(cents: monat.restCents, waehrung: monat.baseCurrency).formatiert()) .font(.title.weight(.semibold).monospacedDigit()) .foregroundStyle(monat.restCents < 0 ? .red : .primary) Text(String(localized: "budget.uebrig")) .font(.subheadline) .foregroundStyle(.secondary) } ProgressView(value: monat.anteilVerbraucht) .tint(monat.restCents < 0 ? .red : .accentColor) HStack { Text(String(format: String(localized: "budget.vonGeplant"), Geldbetrag(cents: monat.totals.actualExpense, waehrung: monat.baseCurrency).formatiert(), Geldbetrag(cents: monat.totals.plannedExpense, waehrung: monat.baseCurrency).formatiert())) Spacer() if monat.customized { // Wer das nicht sieht, wundert sich, warum die Vorlage // andere Zahlen zeigt als dieser Monat. Text(String(localized: "budget.angepasst")) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.quaternary, in: Capsule()) } } .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } } private struct GruppenKopf: View { let gruppe: Budgetgruppe let waehrung: String var body: some View { HStack(spacing: 8) { Image(systemName: Symbole.sf(gruppe.icon)) .foregroundStyle(Color(hex: gruppe.color)) Text(gruppe.name) Spacer(minLength: 8) Text(Geldbetrag(cents: gruppe.actualCents, waehrung: waehrung).formatiert()) .monospacedDigit() .foregroundStyle(gruppe.over && !gruppe.good ? .red : .secondary) Text("/") .foregroundStyle(.tertiary) Text(Geldbetrag(cents: gruppe.plannedCents, waehrung: waehrung).formatiert()) .monospacedDigit() .foregroundStyle(.secondary) } .textCase(nil) .font(.footnote) } } private struct KategorieZeile: View { let kategorie: Budgetkategorie let waehrung: String var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30) VStack(alignment: .leading, spacing: 1) { Text(kategorie.name) .font(.callout) .lineLimit(1) if let unterzeile { Text(unterzeile) .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 1) { Text(Geldbetrag(cents: kategorie.actualCents, waehrung: waehrung).formatiert()) .font(.callout.monospacedDigit()) .foregroundStyle(warnt ? .red : .primary) Text(String(format: String(localized: "budget.vonBetrag"), Geldbetrag(cents: kategorie.plannedCents, waehrung: waehrung).formatiert())) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } } if kategorie.plannedCents > 0 || kategorie.actualCents > 0 { ProgressView(value: kategorie.anteilVerbraucht) .tint(warnt ? .red : (kategorie.good ? .green : Color(hex: kategorie.color))) } } .padding(.vertical, 2) } /// Rot nur bei echter Überschreitung: Mehr sparen als geplant ist keine. private var warnt: Bool { kategorie.over && !kategorie.good } /// Woher der Soll-Wert kommt, wenn er sich nicht einfach ablesen lässt. private var unterzeile: String? { guard kategorie.vertraegeAnzahl > 0 else { return nil } let ausVertraegen = Geldbetrag(cents: kategorie.ausVertraegenCents, waehrung: waehrung).formatiert() if kategorie.istBerechnet { return String(format: String(localized: "budget.ausVertraegenBerechnet"), kategorie.vertraegeAnzahl, ausVertraegen) } return String(format: String(localized: "budget.ausVertraegenHinweis"), kategorie.vertraegeAnzahl, ausVertraegen) } } private struct EinnahmeZeile: View { let posten: GeplanteEinnahme let waehrung: String var body: some View { HStack { Text(posten.label) .font(.callout) Spacer(minLength: 8) Text(Geldbetrag(cents: posten.receivedCents, waehrung: waehrung).formatiert()) .font(.callout.monospacedDigit()) .foregroundStyle(posten.receivedCents >= posten.plannedCents ? .green : .primary) Text("/") .foregroundStyle(.tertiary) Text(Geldbetrag(cents: posten.plannedCents, waehrung: waehrung).formatiert()) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } } /// Verträge, die im Budget fehlen – und Währungen, für die kein Kurs vorlag. /// /// Ohne diesen Hinweis stimmen die Summen still nicht. Genau davor warnt das /// Backend mit den `ohne_kurs`-Listen. private struct VertragsHinweis: View { let reste: Vertragsreste let waehrung: String var body: some View { VStack(alignment: .leading, spacing: 6) { if reste.ohneKategorieAnzahl > 0 { Label { Text(String(format: String(localized: "budget.vertraegeOhneKategorie"), reste.ohneKategorieAnzahl, Geldbetrag(cents: reste.ohneKategorieCents, waehrung: waehrung).formatiert())) } icon: { Image(systemName: "questionmark.circle") } } if !reste.ohneKurs.isEmpty { Label { Text(String(format: String(localized: "budget.ohneKurs"), reste.ohneKurs.joined(separator: ", "))) } icon: { Image(systemName: "exclamationmark.triangle") } .foregroundStyle(.orange) } } .font(.footnote) } }