Compare commits
2
Commits
37b29934ef
...
82db09e6de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82db09e6de | ||
|
|
4c61a02c53 |
@@ -217,13 +217,27 @@ def reset_month(db: Session, user: User, label: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
||||||
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
|
"""Setzt die Standard-Vorlage auf die Soll-Werte dieses Monats
|
||||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
|
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
|
||||||
planned = effective_planned(db, user, label) # {category_id: cents}
|
|
||||||
|
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
|
||||||
|
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
|
||||||
|
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
|
||||||
|
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
|
||||||
|
"""
|
||||||
|
zeilen = list(_zeilen(db, user, label))
|
||||||
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||||
for category_id, cents in planned.items():
|
for zeile in zeilen:
|
||||||
if cents > 0:
|
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
|
||||||
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
|
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
|
||||||
|
# Verträgen, nicht in planned_cents.
|
||||||
|
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
|
||||||
|
continue
|
||||||
|
db.add(BudgetTemplateLine(
|
||||||
|
owner_id=user.id, category_id=zeile.category_id,
|
||||||
|
planned_cents=zeile.planned_cents, modus=zeile.modus,
|
||||||
|
zuschlag_cents=zeile.zuschlag_cents,
|
||||||
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -241,7 +255,13 @@ def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
|
|||||||
db.flush()
|
db.flush()
|
||||||
period.customized = True
|
period.customized = True
|
||||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||||
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
|
period.lines.append(BudgetPeriodLine(
|
||||||
|
category_id=tl.category_id, planned_cents=tl.planned_cents,
|
||||||
|
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
|
||||||
|
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
|
||||||
|
# zuletzt in planned_cents stand.
|
||||||
|
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||||
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
return period
|
return period
|
||||||
|
|
||||||
|
|||||||
@@ -399,3 +399,66 @@ def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
|||||||
assert zeile["planned_cents"] == 12000
|
assert zeile["planned_cents"] == 12000
|
||||||
assert zeile["modus"] == "berechnet"
|
assert zeile["modus"] == "berechnet"
|
||||||
assert zeile["zuschlag_cents"] == 2500
|
assert zeile["zuschlag_cents"] == 2500
|
||||||
|
|
||||||
|
|
||||||
|
def _frische_kategorie(admin, name):
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
gruppe = next(g for g in admin.get("/api/categories").json()
|
||||||
|
if g["kind"] == "expense")
|
||||||
|
return admin.post("/api/categories", json={
|
||||||
|
"group_id": gruppe["id"], "name": f"{name} {secrets.token_hex(3)}"}).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_berechnete_zeile_ueberlebt_den_weg_in_den_monat(admin):
|
||||||
|
"""Vorlage -> Monat darf die Betriebsart nicht verlieren.
|
||||||
|
|
||||||
|
Fiel die Zeile auf "fest" zurueck, stand als Soll die Zahl, die zufaellig
|
||||||
|
zuletzt in planned_cents lag - und der Bezug zu den Vertraegen war weg,
|
||||||
|
ohne dass irgendwo stand, dass er weg ist.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
label = f"{today.year:04d}-{today.month:02d}"
|
||||||
|
kat = _frische_kategorie(admin, "Berechnet-Monat")
|
||||||
|
|
||||||
|
admin.put(f"/api/budget/template/category/{kat}",
|
||||||
|
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 1500})
|
||||||
|
|
||||||
|
r = admin.post(f"/api/budget/{label}/from-template")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
zeile = next(c for g in r.json()["groups"] for c in g["categories"]
|
||||||
|
if c["category_id"] == kat)
|
||||||
|
assert zeile["modus"] == "berechnet"
|
||||||
|
assert zeile["zuschlag_cents"] == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_berechnete_zeile_ueberlebt_den_weg_in_die_vorlage(admin):
|
||||||
|
"""Monat -> Vorlage ebenso: "diesen Monat als Standard festlegen" soll die
|
||||||
|
Betriebsart uebernehmen, nicht die gerade herauskommende Zahl einfrieren."""
|
||||||
|
today = date.today()
|
||||||
|
label = f"{today.year:04d}-{today.month:02d}"
|
||||||
|
kat = _frische_kategorie(admin, "Berechnet-Vorlage")
|
||||||
|
|
||||||
|
admin.put(f"/api/budget/{label}/category/{kat}",
|
||||||
|
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 2500})
|
||||||
|
|
||||||
|
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
zeile = next(l for l in r.json()["lines"] if l["category_id"] == kat)
|
||||||
|
assert zeile["modus"] == "berechnet"
|
||||||
|
assert zeile["zuschlag_cents"] == 2500
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_feste_null_landet_nicht_in_der_vorlage(admin):
|
||||||
|
"""Nichts Geplantes bleibt nichts Geplantes - sonst fuellte sich die
|
||||||
|
Vorlage mit Nullzeilen fuer jede je beruehrte Kategorie."""
|
||||||
|
today = date.today()
|
||||||
|
label = f"{today.year:04d}-{today.month:02d}"
|
||||||
|
kat = _frische_kategorie(admin, "Nullzeile")
|
||||||
|
|
||||||
|
admin.put(f"/api/budget/{label}/category/{kat}", params={"planned_cents": 0})
|
||||||
|
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||||
|
|
||||||
|
assert all(l["category_id"] != kat for l in r.json()["lines"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -5,6 +5,7 @@ struct AboListe: View {
|
|||||||
@Environment(AppZustand.self) private var zustand
|
@Environment(AppZustand.self) private var zustand
|
||||||
@State private var lader: Lader<Aboliste>?
|
@State private var lader: Lader<Aboliste>?
|
||||||
@State private var zeigeBeendete = false
|
@State private var zeigeBeendete = false
|
||||||
|
@State private var formular: AboFormular.Zweck?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -25,6 +26,14 @@ struct AboListe: View {
|
|||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
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 {
|
.task {
|
||||||
guard lader == nil, let client = zustand.client else { return }
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
@@ -51,7 +60,12 @@ struct AboListe: View {
|
|||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
ForEach(sichtbar) { abo in
|
ForEach(sichtbar) { abo in
|
||||||
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
|
Button {
|
||||||
|
formular = .aendern(abo)
|
||||||
|
} label: {
|
||||||
|
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
|
||||||
/// Buchstabe einen Abruf auslöst.
|
/// Buchstabe einen Abruf auslöst.
|
||||||
@State private var abgerufen = ""
|
@State private var abgerufen = ""
|
||||||
|
@State private var formular: BuchungFormular.Zweck?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -47,6 +48,18 @@ struct BuchungsListe: View {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) { filterMenue }
|
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 {
|
.task {
|
||||||
guard lader == nil else { return }
|
guard lader == nil else { return }
|
||||||
@@ -112,7 +125,12 @@ struct BuchungsListe: View {
|
|||||||
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
||||||
Section(gruppe.tag.formatiert()) {
|
Section(gruppe.tag.formatiert()) {
|
||||||
ForEach(gruppe.zeilen) { zeile in
|
ForEach(gruppe.zeilen) { zeile in
|
||||||
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
|
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.
|
/// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label.
|
||||||
@State private var label: String?
|
@State private var label: String?
|
||||||
@State private var lader: Lader<Budgetmonat>?
|
@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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -54,7 +65,22 @@ struct BudgetAnsicht: View {
|
|||||||
ForEach(monat.groups) { gruppe in
|
ForEach(monat.groups) { gruppe in
|
||||||
Section {
|
Section {
|
||||||
ForEach(gruppe.categories) { kategorie in
|
ForEach(gruppe.categories) { kategorie in
|
||||||
KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency)
|
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: {
|
} header: {
|
||||||
GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency)
|
GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency)
|
||||||
@@ -90,6 +116,63 @@ struct BudgetAnsicht: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "budget.naechsterMonat"))
|
.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 */; };
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.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 */; };
|
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.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 */; };
|
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 */; };
|
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.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 */; };
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.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 */; };
|
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.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 */; };
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
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 */; };
|
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -55,6 +61,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -104,6 +116,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
||||||
|
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
|
||||||
);
|
);
|
||||||
path = Konten;
|
path = Konten;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -129,6 +142,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
||||||
|
778040646D05B1DEEE3BF727 /* Formular.swift */,
|
||||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
||||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
||||||
);
|
);
|
||||||
@@ -150,6 +164,7 @@
|
|||||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||||
|
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
|
||||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||||
@@ -220,6 +235,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||||
|
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */,
|
||||||
);
|
);
|
||||||
path = Budget;
|
path = Budget;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -227,6 +243,7 @@
|
|||||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
EFABF1A481445B1CD960C993 /* AboFormular.swift */,
|
||||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||||
);
|
);
|
||||||
path = Abos;
|
path = Abos;
|
||||||
@@ -235,6 +252,7 @@
|
|||||||
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */,
|
||||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||||
);
|
);
|
||||||
path = Buchungen;
|
path = Buchungen;
|
||||||
@@ -364,25 +382,31 @@
|
|||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||||
|
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */,
|
||||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||||
|
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
||||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||||
|
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */,
|
||||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||||
|
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */,
|
||||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||||
|
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
|
||||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||||
|
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct KontenListe: View {
|
|||||||
@Environment(AppZustand.self) private var zustand
|
@Environment(AppZustand.self) private var zustand
|
||||||
@State private var lader: Lader<[Konto]>?
|
@State private var lader: Lader<[Konto]>?
|
||||||
@State private var zeigeArchivierte = false
|
@State private var zeigeArchivierte = false
|
||||||
|
@State private var formular: KontoFormular.Zweck?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -27,6 +28,14 @@ struct KontenListe: View {
|
|||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
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 {
|
.task {
|
||||||
guard lader == nil, let client = zustand.client else { return }
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
@@ -50,7 +59,12 @@ struct KontenListe: View {
|
|||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
ForEach(sichtbar) { konto in
|
ForEach(sichtbar) { konto in
|
||||||
KontoZeile(konto: konto)
|
Button {
|
||||||
|
formular = .aendern(konto)
|
||||||
|
} label: {
|
||||||
|
KontoZeile(konto: konto)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Gesamtsumme(konten: sichtbar,
|
Gesamtsumme(konten: sichtbar,
|
||||||
|
|||||||
@@ -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",
|
try await hole("dashboard/recent",
|
||||||
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
|
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)
|
#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.abbrechen" = "Abbrechen";
|
||||||
"allgemein.abmelden" = "Abmelden";
|
"allgemein.abmelden" = "Abmelden";
|
||||||
"allgemein.unbekannt" = "Unbekannt";
|
"allgemein.unbekannt" = "Unbekannt";
|
||||||
|
"allgemein.sichern" = "Sichern";
|
||||||
|
"allgemein.ok" = "OK";
|
||||||
|
|
||||||
/* --- Reiter --- */
|
/* --- Reiter --- */
|
||||||
"reiter.dashboard" = "Übersicht";
|
"reiter.dashboard" = "Übersicht";
|
||||||
@@ -163,3 +165,77 @@
|
|||||||
"abo.proMonat" = "%@ im Monat";
|
"abo.proMonat" = "%@ im Monat";
|
||||||
"abo.naechste" = "nächste am %@";
|
"abo.naechste" = "nächste am %@";
|
||||||
"abo.inTagen" = "fällig in %d Tagen";
|
"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.abbrechen" = "Cancel";
|
||||||
"allgemein.abmelden" = "Sign out";
|
"allgemein.abmelden" = "Sign out";
|
||||||
"allgemein.unbekannt" = "Unknown";
|
"allgemein.unbekannt" = "Unknown";
|
||||||
|
"allgemein.sichern" = "Save";
|
||||||
|
"allgemein.ok" = "OK";
|
||||||
|
|
||||||
/* --- Tabs --- */
|
/* --- Tabs --- */
|
||||||
"reiter.dashboard" = "Overview";
|
"reiter.dashboard" = "Overview";
|
||||||
@@ -163,3 +165,77 @@
|
|||||||
"abo.proMonat" = "%@ per month";
|
"abo.proMonat" = "%@ per month";
|
||||||
"abo.naechste" = "next on %@";
|
"abo.naechste" = "next on %@";
|
||||||
"abo.inTagen" = "due in %d days";
|
"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