feat: Dritte Stufe der iOS-App - alles laesst sich jetzt bearbeiten
Buchungen anlegen, aendern und loeschen; Budget-Zeilen setzen, fuer den Monat oder fuer die Vorlage; Vertraege und Konten anlegen und aendern. Damit kann die App alles, wofuer man bisher an den Rechner musste - ausser Import, Arbeitsliste und Verwaltung. Ein Formular je Sache, nicht zwei: Anlegen und Aendern teilen sich die Maske, weil die Felder dieselben sind und zwei Kopien genau die Stelle waeren, an der sie auseinanderlaufen. Was sich unterscheidet, steht in einem Zweck-Typ. Geteilt in Bausteine/Formular.swift: ein Speichervorgang, der den Knopf sperrt und den Fehler festhaelt (das Formular bleibt offen - zugeklappt waere die Eingabe weg und die Meldung nutzlos), ein Betragsfeld, das an Cent gebunden ist statt an Text, und die Auswahlfelder fuer Kategorie, Konto und Schlagwoerter. Drei Dinge, die stillschweigend falsch waeren: Eine Teilaenderung schickt nur, was gesetzt ist. Ginge ein nicht angefasstes Feld als null hinaus, loeschte jedes Speichern den Empfaenger, die Notiz und die Kategorie gleich mit. Eine leere Tag-Liste geht dagegen sehr wohl mit - sie bedeutet "alle abnehmen" und darf nicht mit "nicht angefasst" zusammenfallen. Bei einem Uebertrag wird keine Kategorie mitgeschickt: Das Geld wechselt nur die Tasche, es wird nicht eingeordnet. Bei Betriebsart "berechnet" bleibt planned_cents unberuehrt - das Backend liest es dann nicht, und es zu ueberschreiben verloere den Wert, auf den ein Zurueckschalten auf "fest" zurueckfallen soll. Die Waehrung eines bestehenden Kontos laesst sich nicht aendern: Jeder gespeicherte Betrag wuerde danach etwas anderes bedeuten. 49 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einen Vertrag anlegen oder ändern.
|
||||
struct AboFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Abo)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let abo): return abo.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var name = ""
|
||||
@State private var betragCents = 0
|
||||
@State private var waehrung = "EUR"
|
||||
@State private var rhythmus: Zahlungsrhythmus = .monatlich
|
||||
@State private var kategorieId: Int?
|
||||
@State private var kontoId: Int?
|
||||
@State private var hatFaelligkeit = false
|
||||
@State private var faelligAm = Date()
|
||||
@State private var erkennungstext = ""
|
||||
@State private var notiz = ""
|
||||
@State private var istAktiv = true
|
||||
@State private var symbol = "refresh-ccw"
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "abo.name"), text: $name)
|
||||
Betragsfeld(titel: String(localized: "abo.betrag"),
|
||||
waehrung: waehrung, cents: $betragCents)
|
||||
Picker(String(localized: "abo.rhythmus"), selection: $rhythmus) {
|
||||
ForEach(Zahlungsrhythmus.allCases) { einer in
|
||||
Text(einer.name).tag(einer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||||
nachschlag: zustand.nachschlag, art: .expense,
|
||||
auswahl: $kategorieId)
|
||||
KontoFeld(titel: String(localized: "abo.konto"),
|
||||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.kategorieErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "abo.faelligkeitKennen"), isOn: $hatFaelligkeit)
|
||||
if hatFaelligkeit {
|
||||
DatePicker(String(localized: "abo.naechsteZahlung"),
|
||||
selection: $faelligAm, displayedComponents: .date)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "abo.erkennungstext"), text: $erkennungstext)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.erkennungErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "abo.laeuftNoch"), isOn: $istAktiv)
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "abo.beendenStattLoeschen"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(titel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "abo.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "abo.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "abo.neu")
|
||||
case .aendern: return String(localized: "abo.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
private var istVollstaendig: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && betragCents > 0
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let abo) = zweck else {
|
||||
waehrung = zustand.einstellungen.basiswaehrung
|
||||
return
|
||||
}
|
||||
name = abo.name
|
||||
betragCents = abo.amountCents
|
||||
waehrung = abo.currency
|
||||
rhythmus = Zahlungsrhythmus(rawValue: abo.intervalMonths) ?? .monatlich
|
||||
kategorieId = abo.categoryId
|
||||
kontoId = abo.accountId
|
||||
hatFaelligkeit = abo.nextDue != nil
|
||||
faelligAm = abo.nextDue?.datum ?? Date()
|
||||
erkennungstext = abo.payeePattern ?? ""
|
||||
notiz = abo.notes ?? ""
|
||||
istAktiv = abo.isActive
|
||||
symbol = abo.icon
|
||||
farbe = abo.color
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let faellig = hatFaelligkeit ? Tagesdatum(faelligAm) : nil
|
||||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeAboAn(AboAnlegen(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, currency: waehrung,
|
||||
intervalMonths: rhythmus.rawValue, categoryId: kategorieId,
|
||||
accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), notes: leerAlsNil(notiz)))
|
||||
case .aendern(let abo):
|
||||
_ = try await client.aendereAbo(abo.id, AboAendern(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, intervalMonths: rhythmus.rawValue,
|
||||
categoryId: kategorieId, accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), isActive: istAktiv,
|
||||
notes: leerAlsNil(notiz)))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let abo) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheAbo(abo.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func leerAlsNil(_ text: String) -> String? {
|
||||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return sauber.isEmpty ? nil : sauber
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ struct AboListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<Aboliste>?
|
||||
@State private var zeigeBeendete = false
|
||||
@State private var formular: AboFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -25,6 +26,14 @@ struct AboListe: View {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { formular = .neu } label: { Image(systemName: "plus") }
|
||||
.accessibilityLabel(String(localized: "abo.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
AboFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
@@ -51,8 +60,13 @@ struct AboListe: View {
|
||||
}
|
||||
Section {
|
||||
ForEach(sichtbar) { abo in
|
||||
Button {
|
||||
formular = .aendern(abo)
|
||||
} label: {
|
||||
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// Ein laufendes Speichern: sperrt den Knopf und hält den Fehler fest.
|
||||
///
|
||||
/// Vier Formulare brauchen genau dasselbe. Zwei davon hätten es noch je für
|
||||
/// sich machen können, bei vieren liefen die Kopien auseinander.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Speichervorgang {
|
||||
private(set) var laeuft = false
|
||||
var fehler: String?
|
||||
|
||||
/// Führt die Arbeit aus und sagt, ob sie geklappt hat. Ein Fehler bleibt
|
||||
/// in `fehler` stehen, damit das Formular offen bleiben und ihn zeigen
|
||||
/// kann – zugeklappt wäre die Eingabe weg und die Meldung nutzlos.
|
||||
func fuehreAus(_ arbeit: () async throws -> Void) async -> Bool {
|
||||
guard !laeuft else { return false }
|
||||
laeuft = true
|
||||
fehler = nil
|
||||
defer { laeuft = false }
|
||||
do {
|
||||
try await arbeit()
|
||||
return true
|
||||
} catch {
|
||||
fehler = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Fehlerzeile unter einem Formular.
|
||||
struct FehlerZeile: View {
|
||||
let text: String?
|
||||
|
||||
var body: some View {
|
||||
if let text {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Betrag zum Eintippen.
|
||||
///
|
||||
/// Gebunden an **Cent**, nicht an Text: Die Umrechnung an genau einer Stelle
|
||||
/// zu haben ist der Grund, warum `Geldbetrag` existiert. Die Zehnertastatur
|
||||
/// liefert einen Punkt, die deutsche ein Komma – `ausEingabe` nimmt beides.
|
||||
struct Betragsfeld: View {
|
||||
let titel: String
|
||||
let waehrung: String
|
||||
@Binding var cents: Int
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var hatFokus: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(titel)
|
||||
Spacer()
|
||||
TextField(Geldbetrag(cents: 0, waehrung: waehrung).alsEingabe, text: $text)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.monospacedDigit()
|
||||
.focused($hatFokus)
|
||||
.frame(maxWidth: 140)
|
||||
Text(waehrung)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onAppear {
|
||||
// Eine glatte Null als "0,00" vorzublenden sähe aus wie eine
|
||||
// Eingabe. Leer lassen, der Platzhalter sagt genug.
|
||||
if cents != 0 { text = Geldbetrag(cents: cents, waehrung: waehrung).alsEingabe }
|
||||
}
|
||||
.onChange(of: text) { _, neu in
|
||||
cents = Geldbetrag.ausEingabe(neu, waehrung: waehrung)?.cents ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kategorie wählen, nach Gruppen sortiert – wie im Web das Dropdown mit
|
||||
/// Gruppenüberschriften.
|
||||
struct KategorieFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Nur Kategorien dieser Art. `nil` zeigt alle.
|
||||
var art: Kategorieart?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.ohneKategorie")).tag(Int?.none)
|
||||
ForEach(gruppen) { gruppe in
|
||||
Section(gruppe.name) {
|
||||
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
|
||||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gruppen: [Kategoriegruppe] {
|
||||
nachschlag.gruppen
|
||||
.filter { art == nil || $0.kind == art }
|
||||
.filter { $0.categories.contains { !$0.isArchived } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Konto wählen.
|
||||
struct KontoFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Ein Konto, das nicht zur Auswahl stehen soll – das Gegenkonto eines
|
||||
/// Übertrags darf nicht dasselbe sein wie das Quellkonto.
|
||||
var ausser: Int?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.konto.keines")).tag(Int?.none)
|
||||
ForEach(auswaehlbar) { konto in
|
||||
Text(konto.name).tag(Int?.some(konto.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var auswaehlbar: [Konto] {
|
||||
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Schlagwörter einer Buchung an- und abwählen.
|
||||
struct SchlagwortFeld: View {
|
||||
let nachschlag: Nachschlag
|
||||
@Binding var gewaehlt: Set<Int>
|
||||
|
||||
var body: some View {
|
||||
if nachschlag.schlagwoerter.isEmpty {
|
||||
Text(String(localized: "buchung.keineSchlagwoerter"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(nachschlag.schlagwoerter) { etikett in
|
||||
Button {
|
||||
if gewaehlt.contains(etikett.id) { gewaehlt.remove(etikett.id) }
|
||||
else { gewaehlt.insert(etikett.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: etikett.color))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(etikett.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if gewaehlt.contains(etikett.id) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol und Farbe wählen – für Konten und Verträge.
|
||||
struct SymbolUndFarbeFeld: View {
|
||||
@Binding var symbol: String
|
||||
@Binding var farbe: String
|
||||
|
||||
/// Eine kleine Auswahl statt aller 600 Lucide-Namen: Wer mehr braucht,
|
||||
/// stellt es am Rechner ein. Die Namen sind die des Backends, nicht die
|
||||
/// der SF Symbols – `Symbole.sf` übersetzt beim Anzeigen.
|
||||
private static let symbole = [
|
||||
"wallet", "landmark", "piggy-bank", "credit-card", "banknote", "coins",
|
||||
"vault", "trending-up", "home", "shopping-cart", "car", "heart",
|
||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||
]
|
||||
|
||||
private static let farben = [
|
||||
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
|
||||
"#0277bd", "#6b7480", "#5d4037",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||
Image(systemName: Symbole.sf(name))
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: farbe))
|
||||
}
|
||||
|
||||
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auswahl<Inhalt: View>(_ werte: [String], aktuell: String,
|
||||
waehle: @escaping (String) -> Void,
|
||||
@ViewBuilder inhalt: @escaping (String) -> Inhalt) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(werte, id: \.self) { wert in
|
||||
Button { waehle(wert) } label: {
|
||||
inhalt(wert)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(wert == aktuell ? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(wert == aktuell ? Color.accentColor : .clear,
|
||||
lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Buchung anlegen oder ändern.
|
||||
///
|
||||
/// Eine Ansicht für beides: Die Felder sind dieselben, und zwei Formulare
|
||||
/// nebeneinander wären genau die Stelle, an der sie auseinanderlaufen. Was
|
||||
/// sich unterscheidet, steht in `BuchungFormular.Zweck`.
|
||||
struct BuchungFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Buchung)
|
||||
|
||||
/// Damit `.sheet(item:)` damit umgehen kann. Die ID der Buchung
|
||||
/// genügt: Zwei Formulare für dieselbe Buchung gibt es nicht.
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let buchung): return buchung.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
/// Wird nach erfolgreichem Speichern oder Löschen gerufen – die Liste
|
||||
/// dahinter muss sich neu laden.
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var art: Buchungsart = .expense
|
||||
@State private var datum = Date()
|
||||
@State private var kontoId: Int?
|
||||
@State private var zielkontoId: Int?
|
||||
@State private var betragCents = 0
|
||||
@State private var kategorieId: Int?
|
||||
@State private var empfaenger = ""
|
||||
@State private var zweckText = ""
|
||||
@State private var notiz = ""
|
||||
@State private var etiketten: Set<Int> = []
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||||
ForEach(Buchungsart.allCases) { eine in
|
||||
Text(eine.name).tag(eine)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
DatePicker(String(localized: "buchung.datum"), selection: $datum,
|
||||
displayedComponents: .date)
|
||||
|
||||
Betragsfeld(titel: String(localized: "buchung.betrag"),
|
||||
waehrung: waehrung, cents: $betragCents)
|
||||
}
|
||||
|
||||
Section {
|
||||
KontoFeld(titel: String(localized: "buchung.konto"),
|
||||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||||
if art == .transfer {
|
||||
KontoFeld(titel: String(localized: "buchung.zielkonto"),
|
||||
nachschlag: zustand.nachschlag, ausser: kontoId,
|
||||
auswahl: $zielkontoId)
|
||||
} else {
|
||||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||||
nachschlag: zustand.nachschlag,
|
||||
art: art == .income ? .income : .expense,
|
||||
auswahl: $kategorieId)
|
||||
}
|
||||
} footer: {
|
||||
if art == .transfer {
|
||||
Text(String(localized: "buchung.uebertragErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "buchung.empfaenger"), text: $empfaenger)
|
||||
TextField(String(localized: "buchung.verwendungszweck"), text: $zweckText)
|
||||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
}
|
||||
|
||||
Section(String(localized: "buchung.schlagwoerter")) {
|
||||
SchlagwortFeld(nachschlag: zustand.nachschlag, gewaehlt: $etiketten)
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(titel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "buchung.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "buchung.neu")
|
||||
case .aendern: return String(localized: "buchung.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Währung des gewählten Kontos – Beträge stehen immer in ihr.
|
||||
private var waehrung: String {
|
||||
zustand.nachschlag.konto(kontoId)?.currency ?? zustand.einstellungen.basiswaehrung
|
||||
}
|
||||
|
||||
/// Das Backend verlangt einen Betrag grösser null und bei einem Übertrag
|
||||
/// ein Zielkonto. Hier zu prüfen erspart dem Nutzer eine Fehlermeldung
|
||||
/// vom Server für etwas, das die App schon weiss.
|
||||
private var istVollstaendig: Bool {
|
||||
guard betragCents > 0, kontoId != nil else { return false }
|
||||
if art == .transfer { return zielkontoId != nil && zielkontoId != kontoId }
|
||||
return true
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let buchung) = zweck else {
|
||||
kontoId = zustand.nachschlag.konten.first { !$0.isArchived }?.id
|
||||
return
|
||||
}
|
||||
art = buchung.type
|
||||
datum = buchung.date.datum
|
||||
kontoId = buchung.accountId
|
||||
zielkontoId = buchung.toAccountId
|
||||
betragCents = abs(buchung.amountCents)
|
||||
kategorieId = buchung.categoryId
|
||||
empfaenger = buchung.payee ?? ""
|
||||
zweckText = buchung.description ?? ""
|
||||
notiz = buchung.notes ?? ""
|
||||
etiketten = Set(buchung.tagIds)
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client, let kontoId else { return }
|
||||
let tag = Tagesdatum(datum)
|
||||
// Bei einem Übertrag gibt es keine Kategorie – sie mitzuschicken
|
||||
// hiesse, Geld einzuordnen, das nur die Tasche wechselt.
|
||||
let kategorie = art == .transfer ? nil : kategorieId
|
||||
let ziel = art == .transfer ? zielkontoId : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeBuchungAn(BuchungAnlegen(
|
||||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||||
amountCents: betragCents, categoryId: kategorie,
|
||||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||||
case .aendern(let buchung):
|
||||
_ = try await client.aendereBuchung(buchung.id, BuchungAendern(
|
||||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||||
amountCents: betragCents, categoryId: kategorie,
|
||||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let buchung) = zweck else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
try await client.loescheBuchung(buchung.id)
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein leeres Feld ist keine Angabe, sondern das Fehlen einer. Leerzeichen
|
||||
/// als Empfänger zu speichern wäre eine Angabe, die niemand gemacht hat.
|
||||
private func leerAlsNil(_ text: String) -> String? {
|
||||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return sauber.isEmpty ? nil : sauber
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ struct BuchungsListe: View {
|
||||
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
|
||||
/// Buchstabe einen Abruf auslöst.
|
||||
@State private var abgerufen = ""
|
||||
@State private var formular: BuchungFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -47,6 +48,18 @@ struct BuchungsListe: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenue }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
formular = .neu
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(String(localized: "buchung.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
BuchungFormular(zweck: zweck) { Task { await lade() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil else { return }
|
||||
@@ -112,8 +125,13 @@ struct BuchungsListe: View {
|
||||
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
||||
Section(gruppe.tag.formatiert()) {
|
||||
ForEach(gruppe.zeilen) { zeile in
|
||||
Button {
|
||||
formular = .aendern(zeile.buchung)
|
||||
} label: {
|
||||
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ struct BudgetAnsicht: View {
|
||||
/// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label.
|
||||
@State private var label: String?
|
||||
@State private var lader: Lader<Budgetmonat>?
|
||||
@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 {
|
||||
@@ -54,8 +65,23 @@ struct BudgetAnsicht: View {
|
||||
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)
|
||||
}
|
||||
@@ -90,6 +116,63 @@ struct BudgetAnsicht: View {
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Budget-Zeile setzen – für den Monat oder für die Vorlage.
|
||||
///
|
||||
/// Dieselbe Maske für beides, weil das Backend dieselben drei Angaben
|
||||
/// entgegennimmt (`planned_cents`, `modus`, `zuschlag_cents`) und sie über
|
||||
/// `uebernimm` durch dieselbe Regel schickt. Was sich unterscheidet, ist nur
|
||||
/// der Endpunkt und der Satz darunter.
|
||||
struct BudgetZeileBearbeiten: View {
|
||||
enum Ziel: Equatable {
|
||||
/// Wirkt nur auf diesen Monat; er gilt danach als angepasst.
|
||||
case monat(String)
|
||||
/// Wirkt auf alle Monate, die nicht eigens angepasst sind.
|
||||
case vorlage
|
||||
}
|
||||
|
||||
let ziel: Ziel
|
||||
let kategorie: Budgetkategorie
|
||||
let waehrung: String
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var istBerechnet = false
|
||||
@State private var geplantCents = 0
|
||||
@State private var zuschlagCents = 0
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color)
|
||||
Text(kategorie.name)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "budget.ausVertraegenRechnen"),
|
||||
isOn: $istBerechnet)
|
||||
} footer: {
|
||||
Text(istBerechnet ? String(localized: "budget.berechnetErklaerung")
|
||||
: String(localized: "budget.festErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
if istBerechnet {
|
||||
// Bei „berechnet" ist die hingeschriebene Zahl nicht
|
||||
// der Soll-Betrag, sondern nur der Zuschlag obendrauf.
|
||||
LabeledContent(String(localized: "budget.ausVertraegen"),
|
||||
value: Geldbetrag(cents: kategorie.ausVertraegenCents,
|
||||
waehrung: waehrung).formatiert())
|
||||
Betragsfeld(titel: String(localized: "budget.zuschlag"),
|
||||
waehrung: waehrung, cents: $zuschlagCents)
|
||||
LabeledContent(String(localized: "budget.ergibt"),
|
||||
value: Geldbetrag(cents: kategorie.ausVertraegenCents + zuschlagCents,
|
||||
waehrung: waehrung).formatiert())
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
Betragsfeld(titel: String(localized: "budget.geplant"),
|
||||
waehrung: waehrung, cents: $geplantCents)
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(zielTitel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
istBerechnet = kategorie.istBerechnet
|
||||
geplantCents = kategorie.plannedCents
|
||||
zuschlagCents = kategorie.zuschlagCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var zielTitel: String {
|
||||
switch ziel {
|
||||
case .monat: return String(localized: "budget.monatSetzen")
|
||||
case .vorlage: return String(localized: "budget.vorlageSetzen")
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let modus = istBerechnet ? "berechnet" : "fest"
|
||||
// Bei „berechnet" bleibt planned_cents unberührt: Das Backend liest
|
||||
// es dann nicht, und es zu überschreiben verlöre den Wert, auf den
|
||||
// ein Zurückschalten auf „fest" zurückfallen soll.
|
||||
let geplant = istBerechnet ? nil : geplantCents
|
||||
let zuschlag = istBerechnet ? zuschlagCents : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch ziel {
|
||||
case .monat(let label):
|
||||
try await client.setzeBudgetMonat(label, kategorie: kategorie.categoryId,
|
||||
geplantCents: geplant, modus: modus,
|
||||
zuschlagCents: zuschlag)
|
||||
case .vorlage:
|
||||
try await client.setzeBudgetVorlage(kategorie: kategorie.categoryId,
|
||||
geplantCents: geplant, modus: modus,
|
||||
zuschlagCents: zuschlag)
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,15 @@
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */; };
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||
@@ -34,11 +37,14 @@
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -55,6 +61,7 @@
|
||||
/* Begin PBXFileReference section */
|
||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
|
||||
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -65,11 +72,14 @@
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
|
||||
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||
@@ -77,6 +87,7 @@
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungFormular.swift; sourceTree = "<group>"; };
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
|
||||
@@ -88,6 +99,7 @@
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
|
||||
EFABF1A481445B1CD960C993 /* AboFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboFormular.swift; sourceTree = "<group>"; };
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -104,6 +116,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
|
||||
);
|
||||
path = Konten;
|
||||
sourceTree = "<group>";
|
||||
@@ -129,6 +142,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */,
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
||||
);
|
||||
@@ -150,6 +164,7 @@
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
@@ -220,6 +235,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */,
|
||||
);
|
||||
path = Budget;
|
||||
sourceTree = "<group>";
|
||||
@@ -227,6 +243,7 @@
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EFABF1A481445B1CD960C993 /* AboFormular.swift */,
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||
);
|
||||
path = Abos;
|
||||
@@ -235,6 +252,7 @@
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */,
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||
);
|
||||
path = Buchungen;
|
||||
@@ -364,25 +382,31 @@
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */,
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */,
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */,
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||
|
||||
@@ -6,6 +6,7 @@ struct KontenListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Konto]>?
|
||||
@State private var zeigeArchivierte = false
|
||||
@State private var formular: KontoFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -27,6 +28,14 @@ struct KontenListe: View {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { formular = .neu } label: { Image(systemName: "plus") }
|
||||
.accessibilityLabel(String(localized: "konto.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
KontoFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
@@ -50,8 +59,13 @@ struct KontenListe: View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(sichtbar) { konto in
|
||||
Button {
|
||||
formular = .aendern(konto)
|
||||
} label: {
|
||||
KontoZeile(konto: konto)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} footer: {
|
||||
Gesamtsumme(konten: sichtbar,
|
||||
waehrung: zustand.einstellungen.basiswaehrung)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Konto anlegen oder ändern.
|
||||
struct KontoFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Konto)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let konto): return konto.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var name = ""
|
||||
@State private var art: Kontoart = .girokonto
|
||||
@State private var waehrung = "EUR"
|
||||
@State private var anfangssaldoCents = 0
|
||||
@State private var hatStichtag = false
|
||||
@State private var stichtag = Date()
|
||||
@State private var hatKreditrahmen = false
|
||||
@State private var kreditrahmenCents = 0
|
||||
@State private var istGebunden = false
|
||||
@State private var istArchiviert = false
|
||||
@State private var symbol = "wallet"
|
||||
@State private var farbe = "#3f51b5"
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
/// Was das Backend annimmt (`_check_currency`). Mehr braucht ein
|
||||
/// Haushaltsbuch hier nicht; die Hauptwährung steht in den Einstellungen.
|
||||
private static let waehrungen = ["EUR", "CHF", "USD", "GBP"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
Picker(String(localized: "konto.art"), selection: $art) {
|
||||
ForEach(Kontoart.allCases, id: \.self) { eine in
|
||||
Text(eine.name).tag(eine)
|
||||
}
|
||||
}
|
||||
if case .neu = zweck {
|
||||
Picker(String(localized: "konto.waehrung"), selection: $waehrung) {
|
||||
ForEach(Self.waehrungen, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nachträglich zu wechseln liesse jeden gespeicherten
|
||||
// Betrag etwas anderes bedeuten. Deshalb nur zu sehen.
|
||||
LabeledContent(String(localized: "konto.waehrung"), value: waehrung)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Betragsfeld(titel: String(localized: "konto.anfangssaldo"),
|
||||
waehrung: waehrung, cents: $anfangssaldoCents)
|
||||
Toggle(String(localized: "konto.stichtagKennen"), isOn: $hatStichtag)
|
||||
if hatStichtag {
|
||||
DatePicker(String(localized: "konto.stichtag"), selection: $stichtag,
|
||||
displayedComponents: .date)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.anfangssaldoErklaerung"))
|
||||
}
|
||||
|
||||
if art == .kreditkarte {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.kreditrahmenKennen"),
|
||||
isOn: $hatKreditrahmen)
|
||||
if hatKreditrahmen {
|
||||
Betragsfeld(titel: String(localized: "konto.kreditrahmen"),
|
||||
waehrung: waehrung, cents: $kreditrahmenCents)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.kreditrahmenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "konto.gebunden"), isOn: $istGebunden)
|
||||
} footer: {
|
||||
Text(String(localized: "konto.gebundenErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.archivierenStattLoeschen"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(titel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "konto.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "konto.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "konto.neu")
|
||||
case .aendern: return String(localized: "konto.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
private var istVollstaendig: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let konto) = zweck else {
|
||||
waehrung = zustand.einstellungen.basiswaehrung
|
||||
return
|
||||
}
|
||||
name = konto.name
|
||||
art = konto.type
|
||||
waehrung = konto.currency
|
||||
anfangssaldoCents = konto.openingBalanceCents
|
||||
hatStichtag = konto.openingDate != nil
|
||||
stichtag = konto.openingDate?.datum ?? Date()
|
||||
hatKreditrahmen = konto.creditLimitCents != nil
|
||||
kreditrahmenCents = konto.creditLimitCents ?? 0
|
||||
istGebunden = konto.isLocked
|
||||
istArchiviert = konto.isArchived
|
||||
symbol = konto.icon
|
||||
farbe = konto.color
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let datum = hatStichtag ? Tagesdatum(stichtag) : nil
|
||||
// Nur bei Kreditkarten überhaupt ein Rahmen – bei allem anderen wäre
|
||||
// die Zahl bedeutungslos und würde beim Umstellen der Art hängenbleiben.
|
||||
let rahmen = (art == .kreditkarte && hatKreditrahmen) ? kreditrahmenCents : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeKontoAn(KontoAnlegen(
|
||||
name: sauberName, type: art, currency: waehrung, icon: symbol,
|
||||
color: farbe, openingBalanceCents: anfangssaldoCents,
|
||||
openingDate: datum, creditLimitCents: rahmen, isLocked: istGebunden))
|
||||
case .aendern(let konto):
|
||||
_ = try await client.aendereKonto(konto.id, KontoAendern(
|
||||
name: sauberName, type: art, icon: symbol, color: farbe,
|
||||
openingBalanceCents: anfangssaldoCents, openingDate: datum,
|
||||
creditLimitCents: rahmen, isLocked: istGebunden,
|
||||
isArchived: istArchiviert))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
await zustand.ladeNachschlag()
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let konto) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheKonto(konto.id) }) {
|
||||
await zustand.ladeNachschlag()
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
|
||||
// Was die App zum Server schickt. Getrennt von den Lesemodellen, weil das
|
||||
// Backend es ebenso trennt: `TransactionCreate` kennt Felder, die
|
||||
// `TransactionOut` nicht hat, und umgekehrt (`subscription_id` etwa steht nur
|
||||
// in der Ausgabe – beim Anlegen gibt man es nicht mit).
|
||||
//
|
||||
// Alle Änderungstypen sind **teilweise**: Nur was gesetzt ist, geht mit. Ein
|
||||
// `nil` darf nicht als `null` hinausgehen, sonst löschte jedes Speichern die
|
||||
// Felder, die das Formular gar nicht anfasst.
|
||||
//
|
||||
// Das macht Swift von selbst – die erzeugte `encode(to:)` ruft für Optionals
|
||||
// `encodeIfPresent`. Hier stand das eine Weile von Hand ausgeschrieben, bis
|
||||
// ein Test zeigte, dass es dasselbe tut. Der Test bleibt: Die Eigenschaft ist
|
||||
// zu wichtig, um sie ungeprüft einer Spracheigenheit zu überlassen.
|
||||
|
||||
/// `TransactionCreate`
|
||||
struct BuchungAnlegen: Encodable, Sendable {
|
||||
var type: Buchungsart
|
||||
var date: Tagesdatum
|
||||
var accountId: Int
|
||||
var toAccountId: Int?
|
||||
/// Immer positiv – die Richtung steckt in `type`, nicht im Vorzeichen.
|
||||
var amountCents: Int
|
||||
var toAmountCents: Int?
|
||||
var categoryId: Int?
|
||||
var payee: String?
|
||||
var description: String?
|
||||
var notes: String?
|
||||
var tagIds: [Int] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, date, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case amountCents = "amount_cents"
|
||||
case toAmountCents = "to_amount_cents"
|
||||
case categoryId = "category_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// `TransactionUpdate` – nur die gesetzten Felder gehen mit.
|
||||
struct BuchungAendern: Encodable, Sendable {
|
||||
var type: Buchungsart?
|
||||
var date: Tagesdatum?
|
||||
var accountId: Int?
|
||||
var toAccountId: Int?
|
||||
var amountCents: Int?
|
||||
var categoryId: Int?
|
||||
var payee: String?
|
||||
var description: String?
|
||||
var notes: String?
|
||||
/// Mitgeschickt heisst **ersetzen**; eine leere Liste nimmt alle Tags ab.
|
||||
/// Weggelassen lässt sie unberührt.
|
||||
var tagIds: [Int]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, date, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case amountCents = "amount_cents"
|
||||
case categoryId = "category_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountCreate`
|
||||
struct KontoAnlegen: Encodable, Sendable {
|
||||
var name: String
|
||||
var type: Kontoart = .girokonto
|
||||
var currency: String = "EUR"
|
||||
var icon: String = "wallet"
|
||||
var color: String = "#3f51b5"
|
||||
var openingBalanceCents: Int = 0
|
||||
var openingDate: Tagesdatum?
|
||||
var creditLimitCents: Int?
|
||||
var isLocked: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, type, currency, icon, color
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountUpdate` – nur die gesetzten Felder gehen mit.
|
||||
///
|
||||
/// Die Währung fehlt mit Absicht: Sie nachträglich zu wechseln liesse jeden
|
||||
/// gespeicherten Betrag etwas anderes bedeuten. Wer das will, legt ein neues
|
||||
/// Konto an.
|
||||
struct KontoAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var type: Kontoart?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var openingBalanceCents: Int?
|
||||
var openingDate: Tagesdatum?
|
||||
var creditLimitCents: Int?
|
||||
var isLocked: Bool?
|
||||
var isArchived: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, type, icon, color
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
case isArchived = "is_archived"
|
||||
}
|
||||
}
|
||||
|
||||
/// `SubscriptionIn`
|
||||
struct AboAnlegen: Encodable, Sendable {
|
||||
var name: String
|
||||
var icon: String = "refresh-ccw"
|
||||
var color: String = "#6b7480"
|
||||
var amountCents: Int
|
||||
var currency: String = "EUR"
|
||||
/// Nur 1, 2, 3, 6 oder 12 – das Backend weist alles andere ab.
|
||||
var intervalMonths: Int = 1
|
||||
var categoryId: Int?
|
||||
var accountId: Int?
|
||||
var nextDue: Tagesdatum?
|
||||
var payeePattern: String?
|
||||
var notes: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, currency, notes
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case categoryId = "category_id"
|
||||
case accountId = "account_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
}
|
||||
}
|
||||
|
||||
/// `SubscriptionUpdate` – nur die gesetzten Felder gehen mit.
|
||||
struct AboAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var amountCents: Int?
|
||||
var intervalMonths: Int?
|
||||
var categoryId: Int?
|
||||
var accountId: Int?
|
||||
var nextDue: Tagesdatum?
|
||||
var payeePattern: String?
|
||||
var isActive: Bool?
|
||||
var notes: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, notes
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case categoryId = "category_id"
|
||||
case accountId = "account_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die möglichen Zahlungsrhythmen. Mehr nimmt das Backend nicht an.
|
||||
enum Zahlungsrhythmus: Int, CaseIterable, Identifiable, Sendable {
|
||||
case monatlich = 1
|
||||
case zweimonatlich = 2
|
||||
case vierteljaehrlich = 3
|
||||
case halbjaehrlich = 6
|
||||
case jaehrlich = 12
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .monatlich: return String(localized: "abo.monatlich")
|
||||
case .zweimonatlich: return String(format: String(localized: "abo.alleNMonate"), 2)
|
||||
case .vierteljaehrlich: return String(localized: "abo.vierteljaehrlich")
|
||||
case .halbjaehrlich: return String(localized: "abo.halbjaehrlich")
|
||||
case .jaehrlich: return String(localized: "abo.jaehrlich")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,4 +190,102 @@ extension CertainClient {
|
||||
try await hole("dashboard/recent",
|
||||
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Buchungen
|
||||
|
||||
func legeBuchungAn(_ eingabe: BuchungAnlegen) async throws -> Buchung {
|
||||
try await schicke(.post, "transactions", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereBuchung(_ id: Int, _ aenderung: BuchungAendern) async throws -> Buchung {
|
||||
try await schicke(.patch, "transactions/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
func loescheBuchung(_ id: Int) async throws {
|
||||
try await schickeStumm(.delete, "transactions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Budget
|
||||
|
||||
/// Setzt eine Zeile der Monatsansicht. Der Monat wird dadurch als
|
||||
/// „angepasst" geführt und folgt der Vorlage nicht mehr.
|
||||
///
|
||||
/// Query statt Körper – so will es das Backend (`routers/budgets.py`).
|
||||
@discardableResult
|
||||
func setzeBudgetMonat(_ label: String, kategorie: Int, geplantCents: Int?,
|
||||
modus: String? = nil,
|
||||
zuschlagCents: Int? = nil) async throws -> Budgetmonat {
|
||||
try await schicke(.put, "budget/\(label)/category/\(kategorie)",
|
||||
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
|
||||
}
|
||||
|
||||
/// Setzt eine Zeile der Standard-Vorlage. Wirkt auf alle Monate, die
|
||||
/// nicht eigens angepasst sind.
|
||||
@discardableResult
|
||||
func setzeBudgetVorlage(kategorie: Int, geplantCents: Int?, modus: String? = nil,
|
||||
zuschlagCents: Int? = nil) async throws -> Vorlagenzeile {
|
||||
try await schicke(.put, "budget/template/category/\(kategorie)",
|
||||
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
|
||||
}
|
||||
|
||||
private func budgetAbfrage(_ geplantCents: Int?, _ modus: String?,
|
||||
_ zuschlagCents: Int?) -> [URLQueryItem] {
|
||||
var abfrage: [URLQueryItem] = []
|
||||
if let geplantCents {
|
||||
abfrage.append(URLQueryItem(name: "planned_cents", value: String(geplantCents)))
|
||||
}
|
||||
if let modus {
|
||||
abfrage.append(URLQueryItem(name: "modus", value: modus))
|
||||
}
|
||||
if let zuschlagCents {
|
||||
abfrage.append(URLQueryItem(name: "zuschlag_cents", value: String(zuschlagCents)))
|
||||
}
|
||||
return abfrage
|
||||
}
|
||||
|
||||
/// Nimmt die Anpassungen dieses Monats zurück – er folgt wieder der Vorlage.
|
||||
@discardableResult
|
||||
func setzeBudgetMonatZurueck(_ label: String) async throws -> Budgetmonat {
|
||||
try await schicke(.delete, "budget/\(label)")
|
||||
}
|
||||
|
||||
/// Überschreibt den Monat mit der Vorlage.
|
||||
@discardableResult
|
||||
func holeVorlageInMonat(_ label: String) async throws -> Budgetmonat {
|
||||
try await schicke(.post, "budget/\(label)/from-template")
|
||||
}
|
||||
|
||||
/// Macht diesen Monat zur neuen Vorlage.
|
||||
@discardableResult
|
||||
func macheMonatZurVorlage(_ label: String) async throws -> Budgetvorlage {
|
||||
try await schicke(.post, "budget/template/from-month/\(label)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Verträge
|
||||
|
||||
func legeAboAn(_ eingabe: AboAnlegen) async throws -> Abo {
|
||||
try await schicke(.post, "subscriptions", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereAbo(_ id: Int, _ aenderung: AboAendern) async throws -> Abo {
|
||||
try await schicke(.patch, "subscriptions/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
func loescheAbo(_ id: Int) async throws {
|
||||
try await schickeStumm(.delete, "subscriptions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Konten
|
||||
|
||||
func legeKontoAn(_ eingabe: KontoAnlegen) async throws -> Konto {
|
||||
try await schicke(.post, "accounts", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereKonto(_ id: Int, _ aenderung: KontoAendern) async throws -> Konto {
|
||||
try await schicke(.patch, "accounts/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
func loescheKonto(_ id: Int) async throws {
|
||||
try await schickeStumm(.delete, "accounts/\(id)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,3 +578,67 @@ struct ServerantwortenTests {
|
||||
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was zum Server hinausgeht
|
||||
|
||||
@Suite("Eingaben")
|
||||
struct EingabenTests {
|
||||
|
||||
/// Die Felder, die wirklich im JSON stehen.
|
||||
private func felder<A: Encodable>(_ wert: A) throws -> [String: Any] {
|
||||
let roh = try JSONEncoder().encode(wert)
|
||||
return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any])
|
||||
}
|
||||
|
||||
@Test("Eine Teiländerung schickt nur, was gesetzt ist")
|
||||
func teilaenderungBleibtTeilweise() throws {
|
||||
// Der Fall, der stillschweigend Daten frisst: Ginge ein nicht
|
||||
// angefasstes Feld als null hinaus, loeschte jedes Speichern den
|
||||
// Empfänger, die Notiz und die Kategorie gleich mit.
|
||||
let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt"))
|
||||
|
||||
#expect(nurNotiz.keys.sorted() == ["notes"])
|
||||
#expect(nurNotiz["notes"] as? String == "Kassenbon fehlt")
|
||||
}
|
||||
|
||||
@Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas")
|
||||
func leereTagListeGehtMit() throws {
|
||||
// Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab;
|
||||
// sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht
|
||||
// zusammenfallen.
|
||||
let ohneTags = try felder(BuchungAendern(tagIds: []))
|
||||
#expect(ohneTags["tag_ids"] as? [Int] == [])
|
||||
|
||||
let unberuehrt = try felder(BuchungAendern(payee: "Laden"))
|
||||
#expect(unberuehrt["tag_ids"] == nil)
|
||||
}
|
||||
|
||||
@Test("Die Feldnamen sind die des Backends")
|
||||
func feldnamenPassen() throws {
|
||||
let anlegen = try felder(BuchungAnlegen(
|
||||
type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3,
|
||||
amountCents: 4250, categoryId: 7, payee: "Supermarkt"))
|
||||
|
||||
#expect(anlegen["account_id"] as? Int == 3)
|
||||
#expect(anlegen["amount_cents"] as? Int == 4250)
|
||||
#expect(anlegen["category_id"] as? Int == 7)
|
||||
#expect(anlegen["date"] as? String == "2026-10-15")
|
||||
#expect(anlegen["type"] as? String == "expense")
|
||||
}
|
||||
|
||||
@Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist")
|
||||
func weitereTeilaenderungen() throws {
|
||||
#expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"])
|
||||
#expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"])
|
||||
|
||||
let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000))
|
||||
#expect(kreditrahmen["credit_limit_cents"] as? Int == 200000)
|
||||
}
|
||||
|
||||
@Test("Die Rhythmen sind genau die, die das Backend annimmt")
|
||||
func rhythmenPassenZumBackend() {
|
||||
// INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab,
|
||||
// antwortet der Server mit 422 auf etwas, das die Auswahl anbot.
|
||||
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"allgemein.abbrechen" = "Abbrechen";
|
||||
"allgemein.abmelden" = "Abmelden";
|
||||
"allgemein.unbekannt" = "Unbekannt";
|
||||
"allgemein.sichern" = "Sichern";
|
||||
"allgemein.ok" = "OK";
|
||||
|
||||
/* --- Reiter --- */
|
||||
"reiter.dashboard" = "Übersicht";
|
||||
@@ -163,3 +165,77 @@
|
||||
"abo.proMonat" = "%@ im Monat";
|
||||
"abo.naechste" = "nächste am %@";
|
||||
"abo.inTagen" = "fällig in %d Tagen";
|
||||
|
||||
/* --- Buchung bearbeiten --- */
|
||||
"buchung.neu" = "Neue Buchung";
|
||||
"buchung.aendern" = "Buchung ändern";
|
||||
"buchung.datum" = "Datum";
|
||||
"buchung.betrag" = "Betrag";
|
||||
"buchung.konto" = "Konto";
|
||||
"buchung.konto.keines" = "Kein Konto";
|
||||
"buchung.zielkonto" = "Auf Konto";
|
||||
"buchung.kategorie" = "Kategorie";
|
||||
"buchung.empfaenger" = "Empfänger";
|
||||
"buchung.verwendungszweck" = "Verwendungszweck";
|
||||
"buchung.notiz" = "Notiz";
|
||||
"buchung.schlagwoerter" = "Schlagwörter";
|
||||
"buchung.keineSchlagwoerter" = "Noch keine Schlagwörter angelegt. Das geht am Rechner.";
|
||||
"buchung.uebertragErklaerung" = "Ein Übertrag verschiebt Geld zwischen deinen Konten. Er zählt in keiner Auswertung mit und braucht deshalb keine Kategorie.";
|
||||
"buchung.loeschen" = "Buchung löschen";
|
||||
"buchung.loeschenFrage" = "Diese Buchung löschen?";
|
||||
|
||||
/* --- Budget bearbeiten --- */
|
||||
"budget.geplant" = "Geplant";
|
||||
"budget.zuschlag" = "Zuschlag";
|
||||
"budget.ausVertraegen" = "Aus Verträgen";
|
||||
"budget.ergibt" = "Ergibt";
|
||||
"budget.monatSetzen" = "Nur dieser Monat";
|
||||
"budget.vorlageSetzen" = "Standard-Budget";
|
||||
"budget.vorlage" = "Vorlage";
|
||||
"budget.ausVertraegenRechnen" = "Aus Verträgen rechnen";
|
||||
"budget.berechnetErklaerung" = "Der Soll-Betrag ergibt sich aus den Verträgen dieser Kategorie plus Zuschlag. Ändert sich ein Vertrag, ändert sich das Budget mit.";
|
||||
"budget.festErklaerung" = "Ein fester Betrag, den du selbst setzt.";
|
||||
"budget.vorlageHolen" = "Vorlage in diesen Monat holen";
|
||||
"budget.alsVorlageSetzen" = "Diesen Monat als Standard festlegen";
|
||||
"budget.zuruecksetzen" = "Monat zurücksetzen";
|
||||
"budget.zuruecksetzenFrage" = "Diesen Monat zurücksetzen?";
|
||||
"budget.zuruecksetzenErklaerung" = "Alle Anpassungen dieses Monats fallen weg. Danach gilt wieder das Standard-Budget.";
|
||||
|
||||
/* --- Vertrag bearbeiten --- */
|
||||
"abo.neu" = "Neuer Vertrag";
|
||||
"abo.aendern" = "Vertrag ändern";
|
||||
"abo.name" = "Name";
|
||||
"abo.betrag" = "Betrag je Zahlung";
|
||||
"abo.rhythmus" = "Rhythmus";
|
||||
"abo.konto" = "Wird abgebucht von";
|
||||
"abo.kategorieErklaerung" = "Die Kategorie entscheidet, in welcher Budget-Zeile dieser Vertrag mitzählt.";
|
||||
"abo.faelligkeitKennen" = "Nächste Zahlung bekannt";
|
||||
"abo.naechsteZahlung" = "Nächste Zahlung";
|
||||
"abo.erkennungstext" = "Erkennungstext";
|
||||
"abo.erkennungErklaerung" = "Woran die Erkennung diesen Vertrag in einer Buchung wiedererkennt - meist ein Teil des Empfängernamens.";
|
||||
"abo.laeuftNoch" = "Läuft noch";
|
||||
"abo.beendenStattLoeschen" = "Beenden statt löschen hält fest, was er gekostet hat. Löschen entfernt ihn ganz.";
|
||||
"abo.loeschen" = "Vertrag löschen";
|
||||
"abo.loeschenFrage" = "Diesen Vertrag löschen?";
|
||||
"abo.loeschenErklaerung" = "Die zugeordneten Buchungen bleiben, sie verlieren nur das Etikett.";
|
||||
|
||||
/* --- Konto bearbeiten --- */
|
||||
"konto.neu" = "Neues Konto";
|
||||
"konto.aendern" = "Konto ändern";
|
||||
"konto.name" = "Name";
|
||||
"konto.art" = "Art";
|
||||
"konto.waehrung" = "Währung";
|
||||
"konto.anfangssaldo" = "Anfangssaldo";
|
||||
"konto.stichtagKennen" = "Stichtag angeben";
|
||||
"konto.stichtag" = "Stichtag";
|
||||
"konto.anfangssaldoErklaerung" = "Der Stand zu Beginn. Ohne Stichtag gilt er von Anfang an; mit Stichtag zählen nur Buchungen ab diesem Tag darauf.";
|
||||
"konto.kreditrahmenKennen" = "Kreditrahmen angeben";
|
||||
"konto.kreditrahmen" = "Kreditrahmen";
|
||||
"konto.kreditrahmenErklaerung" = "Was die Karte ausgeben darf - kein Guthaben.";
|
||||
"konto.gebunden" = "Gebundenes Geld";
|
||||
"konto.gebundenErklaerung" = "Geld, an das du nicht herankommst, etwa Säule 3a. Es zählt nicht als verfügbar.";
|
||||
"konto.archiviert" = "Archiviert";
|
||||
"konto.archivierenStattLoeschen" = "Archivieren blendet das Konto aus und behält seine Buchungen. Löschen geht nur, solange keine daran hängen.";
|
||||
"konto.loeschen" = "Konto löschen";
|
||||
"konto.loeschenFrage" = "Dieses Konto löschen?";
|
||||
"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren.";
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"allgemein.abbrechen" = "Cancel";
|
||||
"allgemein.abmelden" = "Sign out";
|
||||
"allgemein.unbekannt" = "Unknown";
|
||||
"allgemein.sichern" = "Save";
|
||||
"allgemein.ok" = "OK";
|
||||
|
||||
/* --- Tabs --- */
|
||||
"reiter.dashboard" = "Overview";
|
||||
@@ -163,3 +165,77 @@
|
||||
"abo.proMonat" = "%@ per month";
|
||||
"abo.naechste" = "next on %@";
|
||||
"abo.inTagen" = "due in %d days";
|
||||
|
||||
/* --- Edit transaction --- */
|
||||
"buchung.neu" = "New transaction";
|
||||
"buchung.aendern" = "Edit transaction";
|
||||
"buchung.datum" = "Date";
|
||||
"buchung.betrag" = "Amount";
|
||||
"buchung.konto" = "Account";
|
||||
"buchung.konto.keines" = "No account";
|
||||
"buchung.zielkonto" = "To account";
|
||||
"buchung.kategorie" = "Category";
|
||||
"buchung.empfaenger" = "Payee";
|
||||
"buchung.verwendungszweck" = "Description";
|
||||
"buchung.notiz" = "Note";
|
||||
"buchung.schlagwoerter" = "Tags";
|
||||
"buchung.keineSchlagwoerter" = "No tags yet. You can add them on the desktop.";
|
||||
"buchung.uebertragErklaerung" = "A transfer moves money between your own accounts. It counts in no report and therefore needs no category.";
|
||||
"buchung.loeschen" = "Delete transaction";
|
||||
"buchung.loeschenFrage" = "Delete this transaction?";
|
||||
|
||||
/* --- Edit budget --- */
|
||||
"budget.geplant" = "Planned";
|
||||
"budget.zuschlag" = "Extra";
|
||||
"budget.ausVertraegen" = "From subscriptions";
|
||||
"budget.ergibt" = "Totals";
|
||||
"budget.monatSetzen" = "This month only";
|
||||
"budget.vorlageSetzen" = "Default budget";
|
||||
"budget.vorlage" = "Template";
|
||||
"budget.ausVertraegenRechnen" = "Calculate from subscriptions";
|
||||
"budget.berechnetErklaerung" = "The planned amount comes from this category's subscriptions plus the extra. Change a subscription and the budget follows.";
|
||||
"budget.festErklaerung" = "A fixed amount you set yourself.";
|
||||
"budget.vorlageHolen" = "Load template into this month";
|
||||
"budget.alsVorlageSetzen" = "Make this month the default";
|
||||
"budget.zuruecksetzen" = "Reset month";
|
||||
"budget.zuruecksetzenFrage" = "Reset this month?";
|
||||
"budget.zuruecksetzenErklaerung" = "All adjustments to this month are dropped. The default budget applies again.";
|
||||
|
||||
/* --- Edit subscription --- */
|
||||
"abo.neu" = "New subscription";
|
||||
"abo.aendern" = "Edit subscription";
|
||||
"abo.name" = "Name";
|
||||
"abo.betrag" = "Amount per payment";
|
||||
"abo.rhythmus" = "Billed";
|
||||
"abo.konto" = "Charged to";
|
||||
"abo.kategorieErklaerung" = "The category decides which budget line this subscription counts towards.";
|
||||
"abo.faelligkeitKennen" = "Next payment known";
|
||||
"abo.naechsteZahlung" = "Next payment";
|
||||
"abo.erkennungstext" = "Match text";
|
||||
"abo.erkennungErklaerung" = "How the matcher recognises this subscription in a transaction - usually part of the payee name.";
|
||||
"abo.laeuftNoch" = "Still running";
|
||||
"abo.beendenStattLoeschen" = "Cancelling keeps the record of what it cost. Deleting removes it entirely.";
|
||||
"abo.loeschen" = "Delete subscription";
|
||||
"abo.loeschenFrage" = "Delete this subscription?";
|
||||
"abo.loeschenErklaerung" = "The linked transactions stay; they only lose the label.";
|
||||
|
||||
/* --- Edit account --- */
|
||||
"konto.neu" = "New account";
|
||||
"konto.aendern" = "Edit account";
|
||||
"konto.name" = "Name";
|
||||
"konto.art" = "Type";
|
||||
"konto.waehrung" = "Currency";
|
||||
"konto.anfangssaldo" = "Opening balance";
|
||||
"konto.stichtagKennen" = "Set an opening date";
|
||||
"konto.stichtag" = "Opening date";
|
||||
"konto.anfangssaldoErklaerung" = "The balance to start from. Without a date it applies from the beginning; with one, only transactions from that day count on top.";
|
||||
"konto.kreditrahmenKennen" = "Set a credit limit";
|
||||
"konto.kreditrahmen" = "Credit limit";
|
||||
"konto.kreditrahmenErklaerung" = "What the card may spend - not a balance.";
|
||||
"konto.gebunden" = "Locked money";
|
||||
"konto.gebundenErklaerung" = "Money you cannot reach, such as a pension account. It does not count as available.";
|
||||
"konto.archiviert" = "Archived";
|
||||
"konto.archivierenStattLoeschen" = "Archiving hides the account and keeps its transactions. Deleting only works while none are attached.";
|
||||
"konto.loeschen" = "Delete account";
|
||||
"konto.loeschenFrage" = "Delete this account?";
|
||||
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";
|
||||
|
||||
Reference in New Issue
Block a user