Compare commits
14
Commits
main
...
45fe327840
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45fe327840 | ||
|
|
4d0d5c8d88 | ||
|
|
1686f5769c | ||
|
|
2d36ab0b88 | ||
|
|
62e2b7dee8 | ||
|
|
86a73b862a | ||
|
|
22e509d982 | ||
|
|
76ba79b63e | ||
|
|
33a8abf5cc | ||
|
|
9c41856b5b | ||
|
|
122d4de809 | ||
|
|
5e0ad197f0 | ||
|
|
a3c1bee09e | ||
|
|
317c696e70 |
@@ -0,0 +1,183 @@
|
||||
# Usability-Analyse: Dümpelkas für Menschen mit wenig Computererfahrung
|
||||
|
||||
Stand: 1. November 2026. Untersuchte Quellcodebasis: `9823945` auf `main`; laufende Windows-Debug-Anwendung: Version 26.305 aus `.artifacts/bin/Debug/net10.0`.
|
||||
|
||||
## Ziel und Ergebnis
|
||||
|
||||
Eine gelegentlich arbeitende Kassenführung soll ohne Fachwissen erkennen: Wo bin ich? Was muss ich eingeben? Hat das Speichern funktioniert? Wie komme ich nach einem Fehler zurück?
|
||||
|
||||
Dümpelkas hat dafür eine gute Grundlage: wenige Hauptansichten, große Schaltflächen für häufige Aufgaben, sichtbare Kontostände, Vorzeichen bei Beträgen und wiederherstellbare Buchungen. Die größten Verbesserungen liegen bei verständlichen Beschriftungen, verlässlichen Rückmeldungen, dem Jahreskontext und einer sicheren Datenrettung. Zusätzliche Rückfragen bei jeder gewöhnlichen Buchung würden den Alltag eher erschweren; Bestätigungen sollten auf folgenreiche Aktionen konzentriert bleiben.
|
||||
|
||||
**Empfohlener Start:** zuerst U01–U03 für verlässliche Daten und Ausgaben, danach U04–U08 für die tägliche Bedienung. U09–U16 ergänzen Orientierung, Lesbarkeit und Komfort. Die Priorität bezeichnet eine Empfehlung, keine bereits beschlossene Umsetzung.
|
||||
|
||||
## Vorgehen und Grenzen der Aussage
|
||||
|
||||
- Erneute Quellcodeprüfung von Startseite, Kontoansicht, Buchungs- und Umbuchungsdialog, Übertrag, Bestätigungen, Kontokarten, Buchungslisten, Layout, CSS, Datenpfad, Sicherung und PDF-Ausgabe.
|
||||
- Erneute Live-Prüfung über den Computer-Use-Skill: Kontoansicht mit aktiven und gelöschten Zeilen, Öffnen des neuen Buchungsdialogs, Accessibility-Baum und Fokus, Escape-Verhalten und Abbrechen. Zusätzlich wurde der neue Umbuchungsdialog geöffnet, seine Felder geprüft und ohne Speichern geschlossen. Weitere Dialoge wurden in diesem Durchlauf anhand des Codes beurteilt.
|
||||
- Die bestehende Testbuchung aus dem vorausgehenden Update ist vorhanden. Für diese Analyse werden keine neuen Buchungen angelegt und keine vorhandenen Daten geändert. Keine Sicherung wird zurückgespielt.
|
||||
- Bestehende [Bedienungsanleitung](bedienungsanleitung.md) und [Programmstand](programmstand.md) dienen als Orientierung; die hier aufgeführten Codebefunde wurden erneut an den Quellen geprüft.
|
||||
- Dies ist eine fachliche Oberflächenprüfung durch einen Reviewer, kein Nutzertest mit der Zielgruppe. Aussagen wie „kann verwirren“ sind begründete Hypothesen. Keine gemessenen Fehlerraten, keine behauptete Barrierefreiheitszertifizierung.
|
||||
- Nicht in diesem Durchlauf geprüft: vollständiger Screenreader-Ablauf, Offline-Start, mehrere Bildschirm-Skalierungen, große Datenmengen, echte Wiederherstellung und neu erzeugte PDF-Dateien. Codebefunde dazu sind ausdrücklich von Live-Beobachtungen getrennt.
|
||||
|
||||
## Prioritäten und Aufwand
|
||||
|
||||
**P0:** vor einer verlässlichen Nutzung durch unerfahrene Personen beheben; Datenvertrauen oder Wiederherstellbarkeit betroffen. **P1:** hoher Nutzen für häufige Aufgaben und Fehlervermeidung. **P2:** Komfort und nachhaltige Einführung. Aufwand **S/M/L** ist eine relative Einschätzung für Implementierung einschließlich gezielter Prüfung, keine Zeit- oder Kostenzusage.
|
||||
|
||||
## Empfohlene Maßnahmen mit Umsetzungskriterien
|
||||
|
||||
### U01 · Sicherung und Wiederherstellung müssen dieselben aktiven Daten verwenden
|
||||
|
||||
**P0 · L · Codebefund.** [Programmstart](../src/Duempelkas.Desktop/Program.cs) öffnet `%APPDATA%/Duempelkas/duempelkas.db`; [BackupService](../src/Duempelkas.Infrastructure/Services/BackupService.cs) verwendet die Datei neben dem Prozessprogramm. Eine Person kann deshalb glauben, ihre Buchungen gesichert oder wiederhergestellt zu haben, obwohl eine andere Datei betroffen ist. Zusätzlich ist die rohe Dateikopie bei aktiver SQLite-Nutzung keine hinreichend geprüfte Sicherungsstrategie.
|
||||
|
||||
**Maßnahme:** eine gemeinsame Quelle für den aktiven Datenpfad; konsistente SQLite-Sicherung; Prüfung einer gewählten Sicherung vor dem Austausch; koordinierter Zugriff während der Wiederherstellung; automatische Rückfallsicherung der bisherigen aktiven Daten. Umfang verständlich erklären: derzeit enthält die Sicherung keine Vereinseinstellungen oder Darstellung. Eine automatische Sicherung beim Beenden erst ergänzen, wenn Fehlerbehandlung, Aufbewahrung und Wiederherstellbarkeit festgelegt sind. Eine Kopie auf derselben Festplatte nicht als Schutz gegen Geräteausfall darstellen.
|
||||
|
||||
**Abnahme:** Eine neue Testbuchung ist nach Sicherung, weiterer Änderung und Wiederherstellung in einer isolierten Testumgebung exakt im erwarteten Stand vorhanden. Beschädigte oder ungeeignete Dateien werden vor dem Austausch abgewiesen; die bisherigen Daten bleiben nutzbar. Auch bei SQLite-WAL-Nutzung gehen keine bestätigten Buchungen verloren. Dokumentieren, welche Einstellungen enthalten sind. Wiederherstellung niemals zur Abnahme auf dem echten Vereinsdatenbestand ausprobieren.
|
||||
|
||||
### U02 · Jede Dateioperation mit verständlichem Ergebnis abschließen
|
||||
|
||||
**P0 · M · Codebefund.** [Dashboard.HandleBackupAsync](../src/Duempelkas.App/Pages/Dashboard.razor.cs) zeigt nur Texte an, die „fehlgeschlagen“ enthalten. „Keine Datenbankdatei gefunden.“ und erfolgreiche Sicherungen bleiben damit unsichtbar. [FileSaveService](../src/Duempelkas.Infrastructure/Services/FileSaveService.cs) und PDF-Aufrufer bieten keinen durchgehenden nutzerfreundlichen Fehlerpfad.
|
||||
|
||||
**Maßnahme:** strukturierte Ergebnisse für Erfolg, Abbruch und Fehler; Meldung beispielsweise „Sicherung gespeichert: …“, mit „Ordner öffnen“. Abbruch neutral behandeln. Fehler nennen den nächsten sicheren Schritt, etwa einen anderen Speicherort wählen; technische Details nur auf Wunsch. Während längerer Aktionen Status und Sperre gegen mehrfaches Starten anzeigen.
|
||||
|
||||
**Abnahme:** Erfolg, Abbruch, fehlende Quelldatei, Schreibschutz und fehlgeschlagenes Öffnen sind unterscheidbar. Ein Fehler führt weder zu einer Erfolgsmeldung noch zum Verlust der aktuellen Ansicht. Die Meldung ist auch per Hilfstechnologie wahrnehmbar. Abhängig von U01.
|
||||
|
||||
### U03 · PDF und Bildschirmsummen müssen dieselbe Bedeutung haben
|
||||
|
||||
**P0 · M · Codebefund.** [GenerateStatementAsync](../src/Duempelkas.Infrastructure/Services/PdfStatementService.cs) druckt alle gelieferten Einträge, einschließlich gelöschter Zeilen, ohne Löschkennzeichnung. Die Saldenberechnung lässt gelöschte Einträge aus; die gemeinsame PDF filtert sie ebenfalls aus. Für eine Kassenführung sieht das wie ein Rechenfehler aus.
|
||||
|
||||
**Maßnahme:** normale Auszüge enthalten nur wirksame Buchungen. Falls eine Historie gewünscht ist, diese ausdrücklich als separaten Modus mit Status und erklärter Summenbildung anbieten. Zeitraum, Konto und Bedeutung des Endstands in jedem Export eindeutig nennen.
|
||||
|
||||
**Abnahme:** Beispiel mit Einnahme, Ausgabe, Umbuchung und gelöschter Buchung liefert erklärbar gleiche Summen in Kontoansicht, Einzelkonto-PDF und gemeinsamer Übersicht. Die unterschiedliche Darstellung interner Umbuchungen muss dabei ausdrücklich berücksichtigt werden. Tests prüfen Inhalte und Summen; eine visuelle PDF-Prüfung kontrolliert Lesbarkeit und Seitenumbrüche.
|
||||
|
||||
### U04 · Jahreskontext beim Navigieren und Buchen erhalten
|
||||
|
||||
**P1 · M · Codebefund.** [Dashboard](../src/Duempelkas.App/Pages/Dashboard.razor.cs) und [AccountDetail](../src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs) starten jeweils unabhängig im aktuellen Jahr. Neue Dialoge verwenden immer das heutige Datum. Eine Buchung kann damit außerhalb des sichtbaren Jahres gespeichert werden; ein auf der Startseite gewähltes Jahr geht beim Kontoaufruf verloren.
|
||||
|
||||
**Maßnahme:** „Jahr: 2026“ statt „Ansicht 2026“, Filter beim Wechsel zwischen Übersicht und Konto erhalten, aktuellen Zeitraum neben Liste und Summen nennen. Bei abweichendem Buchungsjahr vor dem Speichern gezielt informieren und danach „Buchung im Jahr 2025 anzeigen“ anbieten. Kein stilles Umsetzen des Datums auf ein anderes Jahr. Bei leerer Liste zwischen „noch keine Buchungen“ und „keine Buchungen in diesem Zeitraum“ unterscheiden.
|
||||
|
||||
**Abnahme:** Jahr 2025 auf der Übersicht wählen, Konto öffnen und zurückkehren erhält 2025. Eine Buchung mit Datum 2026 wird im 2025-Filter erklärt und auffindbar gemacht. Auch „Alle Jahre“, Jahreswechsel und eine noch leere Jahresauswahl funktionieren. Ein fachlich unverändertes Datum darf nicht allein aus Bequemlichkeit umgeschrieben werden.
|
||||
|
||||
### U05 · Aufgaben in Alltagssprache benennen und gewichten
|
||||
|
||||
**P1 · S · Code und sichtbare Kontoansicht.** Große Schaltflächen sind bereits hilfreich; „Buchung“, „PDF“, „Restore“, „Saldo“ und „Umsätze“ verlangen jedoch Vorwissen. Auf der Startseite stehen häufige und seltene Aktionen gleichberechtigt nebeneinander.
|
||||
|
||||
**Maßnahme:** „Einnahme oder Ausgabe erfassen“, „Geld zwischen Konten verschieben“, „Als PDF speichern“, „Sicherung erstellen“, „Sicherung zurückspielen“. „Kontostand“ statt „Saldo“; „Änderung im Jahr“ statt des uneindeutigen Netto-Begriffs „Umsätze“. Eine konsistente Anrede verwenden. Sicherung zurückspielen unter klar benannten weiteren Aktionen platzieren, weiterhin auffindbar. Sichtbarer Hinweis beim Kontenverschieben: „Erfasst nur die Buchung. Führt keine Banküberweisung aus.“
|
||||
|
||||
**Abnahme:** Beschriftungen passen ohne Abschneiden in die überarbeitete Leiste; die bisherige feste Breite von 75 px muss dafür angepasst werden. Eine Testperson findet Erfassen, PDF und Sicherung ohne Erklärung von Symbolen. Vertraute Begriffe wie „Umbuchung“ können als ergänzende Erläuterung erhalten bleiben.
|
||||
|
||||
### U06 · Buchungsformular erklärt Konto, Art und ungültige Eingaben
|
||||
|
||||
**P1 · M · Code und Live-Dialog.** Der neue Dialog zeigt „Neue Buchung“, standardmäßig Einnahme, Betrag 0 und einen deaktivierten Hinzufügen-Knopf. Es fehlt ein sichtbarer Grund für die Sperre; das betroffene Konto steht nicht im Dialog. Beim Bearbeiten fehlt die Art-Auswahl vollständig.
|
||||
|
||||
**Maßnahme:** Konto im Titel nennen, Einnahme/Ausgabe deutlich auswählbar machen und beim Bearbeiten wenigstens sichtbar anzeigen. Beispiele wie „Mitgliedsbeitrag“ oder „Porto“; Hinweis „Betrag ohne Minuszeichen“. Feldnahe Hinweise bei fehlender Beschreibung, ungültigem Datum oder Betrag. Eingaben `12,50`, `12.50` und eingefügte Beträge bewusst spezifizieren und ohne heimliche Wertänderung behandeln. Eine versehentlich falsche Buchungsart braucht einen verständlichen Korrekturweg; vor einer direkten Änderung fachliche Regeln festlegen.
|
||||
|
||||
**Abnahme:** Leeres Formular erklärt, was fehlt. Deutsche Dezimaleingaben und mehr als zwei Nachkommastellen haben ein definiertes, geprüftes Verhalten. Negative Beträge werden verständlich behandelt. Das Speichern bleibt nach Fehlern mit erhaltenen Eingaben möglich; die Buchungsart ist jederzeit erkennbar.
|
||||
|
||||
### U07 · Speichern bestätigen und Doppelbuchungen verhindern
|
||||
|
||||
**P1 · M · Codebefund.** [AddEntryDialog](../src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs) und [AddTransferDialog](../src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs) haben keinen sichtbaren Speichervorgang und keine eigene Sperre gegen erneutes Auslösen. Nach Erfolg schließt der Dialog ohne ausdrückliche Bestätigung auf der Kontoseite.
|
||||
|
||||
**Maßnahme:** während des Speicherns „Wird gespeichert …“, Aktion gegen Mehrfachauslösung schützen, nach Erfolg „Einnahme über 12,50 € gespeichert“ und die neue Zeile kurz hervorheben. Bei Fehlern Eingaben erhalten. Für Serienerfassung optional „Speichern und weitere erfassen“ ergänzen, ohne die normale Aktion zu verkomplizieren. Ähnliche Buchungen allenfalls als Hinweis behandeln, da gleiche Beträge legitim sind.
|
||||
|
||||
**Abnahme:** Doppelklick und wiederholtes Enter während einer verzögerten Speicherung erzeugen genau eine Buchung. Ein fehlgeschlagener Versuch erzeugt keine irreführende Bestätigung. Erneutes bewusstes Erfassen bleibt möglich. Kein zusätzlicher Bestätigungsdialog bei jeder normalen Einnahme.
|
||||
|
||||
### U08 · Dialoge und Kontokarten vollständig per Tastatur bedienen
|
||||
|
||||
**P1 · M · Code und Live-Teilprüfung.** Dialoge bestehen aus allgemeinen `div`-Elementen, Beschriftungen sind überwiegend nicht per `for`/`id` mit Feldern verbunden. [Kontokarten](../src/Duempelkas.App/Components/Accounts/AccountCard.razor) sind klickbare `div`s. Beim live geöffneten Buchungsdialog wurde kein Eingabefeld automatisch fokussiert; Escape schloss ihn nicht. Der Accessibility-Baum enthielt weiterhin Hintergrundelemente; ein vollständiger Screenreader-Test wurde nicht durchgeführt.
|
||||
|
||||
**Maßnahme:** semantische Links für Konten, echte Schaltflächen für Aktionen, Dialogrolle und beschrifteter Titel, Fokus ins erste sinnvolle Feld, Fokus im Dialog halten und beim Schließen zum Auslöser zurückgeben. Escape schließt unveränderte Dialoge. Bei veränderten Eingaben Verlust abfangen. Enter nur in passenden Formularen als Speichern, nie als unbeabsichtigte Löschbestätigung. Labels, Fehlermeldungen und Eingaben technisch verbinden.
|
||||
|
||||
**Abnahme:** Konto öffnen, Buchung eingeben und abbrechen sind ausschließlich mit Tab, Umschalt-Tab, Enter und Escape möglich. Während eines modalen Dialogs sind Hintergrundaktionen nicht bedienbar. Fokus ist sichtbar, Titel/Feldname/Fehler werden vorgelesen. Unsichere Bestätigungen starten auf Abbrechen.
|
||||
|
||||
### U09 · Kleine Symbolaktionen lesbar und leichter treffbar machen
|
||||
|
||||
**P1 · S · Code und Live-Ansicht.** Stift, Papierkorb und Wiederherstellen sind kleine Symbolknöpfe. Tooltips helfen erst beim Darüberfahren; in langen Zeilen ist die Zuordnung fehleranfällig. `title`-Attribute sind bereits vorhanden, ersetzen aber keine sichtbare Erklärung.
|
||||
|
||||
**Maßnahme:** größere Trefferflächen und sichtbare Texte oder ein pro Zeile beschriftetes Aktionsmenü. Als Projektziel etwa 40–44 CSS-Pixel hohe Trefferbereiche vorsehen. Eindeutige zugängliche Namen mit Buchungsbezug; Bearbeiten und Löschen mit ausreichend Abstand. Wiederherstellen als Wort anbieten.
|
||||
|
||||
**Abnahme:** ungeübte Mausbedienung trifft die gewünschte Aktion zuverlässig, auch bei Windows-Skalierung. Tab-Fokus nennt die zugehörige Buchung. Kein Verlust von Tabelleninhalt durch unnötig große Aktionsspalten; bei schmaler Ansicht ein geprüftes Alternativlayout.
|
||||
|
||||
### U10 · Löschen, Wiederherstellen und verbundene Umbuchungen erklären
|
||||
|
||||
**P1 · M · Code und sichtbare gelöschte Zeilen.** Durchgestrichene Buchungen bleiben zwischen aktiven Zeilen stehen. Die [Bestätigungen](../src/Duempelkas.App/Pages/Accounts/AccountDetail.razor) nennen nur den Titel; [EntryService](../src/Duempelkas.Infrastructure/Services/EntryService.cs) verändert bei Umbuchungen auch die Gegenbuchung.
|
||||
|
||||
**Maßnahme:** gelöschte Einträge standardmäßig ausblenden, mit „Gelöschte Buchungen anzeigen (2)“ erreichbar halten; sichtbarer Status „Gelöscht – zählt nicht zum Kontostand“. Bestätigung mit Datum, Betrag und Konto. Bei Umbuchungen beide betroffenen Konten und gemeinsame Wirkung nennen. Nach Erfolg eine klar zugeordnete Rückgängig-Aktion anbieten, soweit fachlich und technisch eindeutig.
|
||||
|
||||
**Abnahme:** Entfernen und Wiederherstellen einer Umbuchung aktualisiert beide Seiten nachvollziehbar; Bestätigung benennt dies vorab. Versehentliches Löschen ist ohne Fachwissen rückgängig zu machen. Eine sichtbare Historie darf nicht als mitgerechnete Buchung missverstanden werden. Abhängig von U03 für konsistente Exporte.
|
||||
|
||||
### U11 · Umbuchungen als Richtung zwischen zwei Konten zeigen
|
||||
|
||||
**P1 · M · Codebefund.** [AddTransferDialog](../src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor) zeigt nur „Zielkonto“. Beim Bearbeiten einer empfangenen Buchung ist das angezeigte verknüpfte Konto jedoch das abgebende Konto. Das begünstigt eine falsche Interpretation.
|
||||
|
||||
**Maßnahme:** immer „Von: Barkasse → Nach: Girokonto“ zeigen; auf beiden Buchungsseiten dieselbe fachliche Richtung. Den festen bzw. änderbaren Teil ausdrücklich benennen. Kurze Zusammenfassung „Barkasse −50,00 €, Girokonto +50,00 €; Gesamtsumme unverändert“. Bei nur einem vorhandenen Konto erklären, warum keine Umbuchung möglich ist.
|
||||
|
||||
**Abnahme:** Öffnen von beiden Seiten zeigt dieselbe Richtung. Änderungen betreffen die beabsichtigte Seite und behalten die Verknüpfung. Gleiche Quelle und Ziel werden auch im Dienst abgewiesen. Geld wird nicht doppelt als Vereinseinnahme dargestellt.
|
||||
|
||||
### U12 · Anfangsbestand und Jahresübertrag vor unbeabsichtigter Änderung schützen
|
||||
|
||||
**P1 · L · Codebefund.** [UpdateCarryoverAsync](../src/Duempelkas.Infrastructure/Services/AccountService.cs) verändert den gemeinsamen Anfangsbestand anhand des gewünschten Jahresübertrags. Der [Dialog](../src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor) zeigt weder Jahr noch Folgen. Ein scheinbarer lokaler Jahreswert kann andere Jahre beeinflussen.
|
||||
|
||||
**Maßnahme:** Einrichtung eines neuen Kontos und spätere Bestandskorrektur getrennt erklären. Einfache Formel anzeigen: „Anfangsbestand + Einnahmen − Ausgaben = Kontostand“. Spätere Änderung als bewusste Korrektur mit vorher/nachher und Hinweis auf betroffene Jahre. Ob künftig jahresbezogene Korrekturen statt eines gemeinsamen Anfangsbestands gebraucht werden, muss fachlich entschieden werden; das ist keine reine Textänderung.
|
||||
|
||||
**Abnahme:** Eine Testperson kann die Wirkung vor dem Speichern richtig erklären. Automatisierte Mehrjahresbeispiele prüfen die vereinbarte Semantik. Bei Änderung der Speicherung sind Datenmigration und Bestandserhalt eigene Abnahmepunkte.
|
||||
|
||||
### U13 · Schrift, Kontrast und Fenstergröße an reale Arbeitsplätze anpassen
|
||||
|
||||
**P1 · M · CSS und Live-Ansicht; keine gemessene Kontrastbewertung.** [app.css](../src/Duempelkas.Desktop/wwwroot/css/app.css) verwendet viele kleine Schriftgrößen und halbtransparente gelöschte Zeilen. Die Dialoge haben feste Mindestbreiten und keine eigene begrenzte Höhe mit Scrollbereich. Im aufgenommenen dunklen Fenster sind Zusatztexte und Symbolaktionen deutlich kleiner als die Hauptsumme.
|
||||
|
||||
**Maßnahme:** gut lesbare Grundschrift, weniger kleine Großbuchstaben, skalierbare Abstände und optional „Schriftgröße: normal/groß“. Beide Farbdarstellungen mit gemessenem Kontrast prüfen, Zeichen und Text zusätzlich zu Farben erhalten. Dialoge müssen innerhalb des verfügbaren Fensters scrollbar bleiben; wichtige Knöpfe dürfen nicht außerhalb des sichtbaren Bereichs liegen.
|
||||
|
||||
**Abnahme:** alle Hauptaufgaben im unterstützten Mindestfenster 768 × 480 sowie bei 125/150/200 % Windows-Skalierung prüfen. Lange Namen, Beträge und Fehlermeldungen bleiben lesbar; kein verdecktes Speichern/Abbrechen. Helle und dunkle Darstellung getrennt kontrollieren. Kein Versprechen vollständiger Barrierefreiheit allein aufgrund dieser Stichprobe.
|
||||
|
||||
### U14 · Erste Nutzung und Hilfe direkt am Bedarf anbieten
|
||||
|
||||
**P2 · M · Codebefund.** Die leere Startseite fordert zum ersten Konto auf; der Kontodialog fragt nur nach einem Namen. Die vorhandene ausführliche Anleitung ist im [Layout](../src/Duempelkas.App/Components/Layout/MainLayout.razor) nicht als Hilfe erreichbar.
|
||||
|
||||
**Maßnahme:** überspringbare kurze Einführung: Vereinsname, erstes Konto mit Beispielen, erklärter Anfangsbestand, erste Buchung, Sicherung. „Hilfe“ sichtbar anbieten, lokal verfügbar und passend zur Programmversion. Kurze Beispiele direkt bei Übertrag und Umbuchung; vollständige Anleitung ergänzend. Keine Pflichtregistrierung und keine langen Erklärungen vor jeder Buchung.
|
||||
|
||||
**Abnahme:** eine neue Person richtet mit selbst gewählten Angaben ein Konto ein und findet die Hilfe ohne Internet. Überspringen und späteres Fortsetzen funktionieren. Keine automatischen Beispielbuchungen im echten Datenbestand. U01 und U12 vor einem geführten Sicherungs-/Anfangsbestandsablauf umsetzen.
|
||||
|
||||
### U15 · Buchungen ohne langes Scrollen wiederfinden
|
||||
|
||||
**P2 · M · Codebefund.** [EntryTable](../src/Duempelkas.App/Components/Accounts/EntryTable.razor) bietet keine Suche und keine bedienbare Sortierung. Bei vielen Einträgen reichen Jahresfilter und fortlaufende Nummer allein nicht aus.
|
||||
|
||||
**Maßnahme:** einfache Suche nach Bezeichnung oder Buchungsnummer, optional Datum und Betrag. Aktive Filter mit „Filter zurücksetzen“ sichtbar machen. Falls später Seitennavigation nötig wird, Trefferzahl und Position zeigen. Bei gefilterter Liste eindeutig zwischen Kontostand des Jahres und Summe der Treffer unterscheiden.
|
||||
|
||||
**Abnahme:** eine bekannte Buchung wird aus einem größeren Beispieldatenbestand ohne Durchscrollen gefunden. „Keine Treffer“ enthält eine Rückkehr zur vollständigen Liste. Suchen ändert den Kontostand nicht stillschweigend. Sonderzeichen und deutsche Betragsnotation sind berücksichtigt.
|
||||
|
||||
### U16 · Offline-Betrieb und verständliche Fehlerauswege absichern
|
||||
|
||||
**P1 · M · Codebefund, Offline-Auswirkung noch zu prüfen.** [index.html](../src/Duempelkas.Desktop/wwwroot/index.html) lädt Bootstrap und Symbole von externen CDNs. Das allgemeine Fehlerbanner spricht von einem „unbehandelten Fehler“. Die Nicht-gefunden-Ansicht bietet keinen eigenen Rückweg.
|
||||
|
||||
**Maßnahme:** benötigte Darstellungsmittel lokal mitliefern, wenn das Kassenbuch offline nutzbar sein soll. Fehlertexte mit „Was ist passiert?“ und sicherer nächster Aktion; keine technischen Stapelspuren als einzige Hilfe. Eingaben nach erwartbaren Speicherfehlern erhalten. Für unerwartete Fehler einen Hinweis zur Meldung, mit optional kopierbaren technischen Details ohne unnötige personenbezogene Buchungsdaten. Nicht gefunden: „Zur Kontenübersicht“ anbieten.
|
||||
|
||||
**Abnahme:** Start ohne Netz und mit leerem WebView-Cache zeigt lesbare, bedienbare Oberflächen einschließlich Symbolen. Simulierter Speicherfehler bewahrt Eingaben; Neuladen behauptet keinen unbestätigten Speichervorgang. Offline-Verhalten ist vor einer entsprechenden Produktzusage getestet.
|
||||
|
||||
## Vorschlag für spätere Umsetzungspakete
|
||||
|
||||
Die IDs bleiben stabil, damit dieser Pull Request als Diskussions- und Auswahlgrundlage dienen kann. Eine Auswahl bedeutet noch keine abgeschlossene Umsetzung.
|
||||
|
||||
- [ ] **A – Verlässliche Daten:** U01, U02, U03; isolierte Sicherungs-/Restore-Tests und PDF-Inhaltsprüfung zuerst.
|
||||
- [ ] **B – Sicher erfassen:** U04, U06, U07, U11, U12; Jahreskontext, Eingabe und fachliche Auswirkungen gemeinsam behandeln.
|
||||
- [ ] **C – Verständlich bedienen:** U05, U08, U09, U10, U13; konsistente Komponenten für Dialoge und Aktionen verwenden.
|
||||
- [ ] **D – Selbstständig arbeiten:** U14, U15, U16; Offline-Grundlage U16 kann wegen P1 vorgezogen werden.
|
||||
|
||||
**Bewusst zurückstellen:** Bankanbindung, umfangreiche Kategorien, neue Auswertungszentralen, automatische fachliche Korrekturen und ein vollständiger Umbau der Oberfläche. Dafür liegt hier kein belegter Bedarf vor. Auch eine Jahressperre wäre ein eigener fachlicher Auftrag, keine beiläufige Usability-Änderung.
|
||||
|
||||
## Validierung mit der tatsächlichen Zielgruppe
|
||||
|
||||
Vor größerer Umsetzung drei bis fünf Personen mit geringer Computererfahrung an einem separaten Beispieldatenbestand beobachten. Diese kleine Runde liefert qualitative Hinweise, keine repräsentativen Kennzahlen. Hilfestellung erst geben, wenn eine Aufgabe sonst abgebrochen würde; Stellen und Gründe protokollieren.
|
||||
|
||||
1. Ein Konto öffnen und seinen Kontostand erklären.
|
||||
2. Einen Mitgliedsbeitrag von 12,50 € und eine Ausgabe von 4,20 € erfassen.
|
||||
3. Eine Buchung aus dem Vorjahr finden und erklären, weshalb sie im aktuellen Jahr nicht sichtbar ist.
|
||||
4. Eine versehentlich erfasste Buchung korrigieren; Löschen und Rückweg verstehen.
|
||||
5. 50 € von Barkasse nach Girokonto umbuchen und beide Kontostände sowie die Vereinssumme erklären.
|
||||
6. Einen Jahresauszug speichern und die Datei wiederfinden.
|
||||
7. Eine Sicherung erstellen, deren Speicherort nennen und in einer isolierten Testumgebung den vorherigen Stand wiederherstellen.
|
||||
|
||||
Je Aufgabe festhalten: ohne Hilfe abgeschlossen, Zahl der nötigen Hilfen, Fehlbuchungen, verlorene Eingaben, subjektive Sicherheit sowie auffällige Begriffe. Zeiten können Orientierung geben; zunächst keine willkürlichen Zeitgrenzen als Erfolgsversprechen setzen. Ziel ist, dass die Kernaufgaben ohne Erklärung der Symbole und ohne Eingriff einer technischen Hilfsperson gelingen.
|
||||
|
||||
## Prüfnachweis für diesen Dokumentations-PR
|
||||
|
||||
Nur Analyse und Planung; keine Änderung am Programmverhalten. Vor Veröffentlichung Markdown-Verweise auf lokale Quellen, eindeutige Maßnahmen-IDs und `git diff --check` prüfen. Automatisierte Anwendungstests werden für diese reine Dokumentationsänderung nicht erneut ausgeführt; die vorherige Prüfung des Basiscommits bestand mit 12/12 Tests in Debug und Release. Das ersetzt keine spätere Abnahme der hier empfohlenen Änderungen.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Umsetzung U01–U07
|
||||
|
||||
Reihenfolge und je ein eigener Commit:
|
||||
|
||||
1. **U01:** Gemeinsamer Datenpfad, SQLite-Online-Sicherung, geprüfte Wiederherstellung mit Rückfallsicherung und koordinierten Schreibzugriffen. Nur isolierte Testdaten wiederherstellen.
|
||||
2. **U02:** Strukturierte Ergebnisse (Erfolg, Abbruch, Fehler), sichtbare Meldungen, sichere Dateiaktionen und Sperre während Dateioperationen.
|
||||
3. **U03:** Nur wirksame Buchungen exportieren; Zeitraum, Anfangs-/Endstand und interne Umbuchungen erklären. PDF-Inhalte und Darstellung prüfen.
|
||||
4. **U04:** Gemeinsamer Jahreskontext, sichtbarer Zeitraum, Hinweis und Rückweg bei abweichendem Buchungsjahr; Datum beibehalten.
|
||||
5. **U05:** Alltagssprache, flexible Aktionsleiste, Wiederherstellung unter weiteren Aktionen, Hinweis zur Umbuchung.
|
||||
6. **U06:** Konto und Art anzeigen, feldnahe Validierung. Komma oder Punkt als Dezimaltrenner, keine Tausendertrenner; höchstens zwei Nachkommastellen, positive Beträge. Art beim Bearbeiten fest: falsche Buchung löschen und neu erfassen.
|
||||
7. **U07:** Speichersperre, Fortschritt, Fehler mit erhaltenen Eingaben, ausdrückliche Erfolgsmeldung und Hervorhebung.
|
||||
|
||||
Prüfung: gezielte automatisierte Regressionen, Debug/Release, Desktop-Prüfung und PDF-Sichtprüfung soweit verfügbar. Anschließend Commits pushen und PR-Beschreibung mit Ergebnissen und Grenzen aktualisieren. U08–U16 bleiben außerhalb dieses Auftrags.
|
||||
|
||||
## U01 – Sicherungsumfang
|
||||
|
||||
Die Sicherung enthält Konten, Anfangsbestände, Buchungen einschließlich gelöschter Zeilen, Umbuchungsverknüpfungen und Datenbankschema. Vereinsname, Farbdarstellung und Fenstergröße liegen außerhalb der Datenbank und sind **nicht enthalten**. Eine Kopie auf derselben Festplatte schützt nicht gegen Geräteausfall; dafür die Sicherung zusätzlich auf einem unabhängigen Datenträger aufbewahren.
|
||||
|
||||
Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden.
|
||||
|
||||
Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt.
|
||||
|
||||
## U02 – Dateiaktionen
|
||||
|
||||
Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden.
|
||||
|
||||
## U03 – Gleiche Bedeutung in PDF und Oberfläche
|
||||
|
||||
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
|
||||
|
||||
## U04 – Jahreskontext
|
||||
|
||||
Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück erhalten, auch „Alle Jahre“. Auswahl enthält aktuelles und gewähltes Jahr auch ohne Buchungen. Liste und Summen benennen den Zeitraum; Leerzustände unterscheiden fehlende Buchungen und leeren Zeitraum. Abweichendes Datum bleibt unverändert, erfordert eine gezielte Bestätigung im Formular und bietet nach Erfolg „Buchung im Jahr … anzeigen“. Prüfung: 21/21 Debug-Tests, einschließlich gerenderter Navigation 2025 → Konto → Zurück, Buchung 2026 bei Filter 2025 und anschließendem Aufruf des gespeicherten Jahres. Dabei bestehende doppelte DbContext-Registrierung korrigiert, da die Singleton-Factory sonst scoped Optionen konsumiert.
|
||||
|
||||
## U05 – Alltagssprache
|
||||
|
||||
Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt.
|
||||
|
||||
## U06 – Verständliche Eingabe
|
||||
|
||||
Konto im Dialogtitel, sichtbare Art beim Bearbeiten und erklärter Korrekturweg: abbrechen, falsche Buchung löschen, neu erfassen. Keine Änderung der Buchungsart bestehender Daten. Feldnahe, technisch zugeordnete Hinweise für Beschreibung, Datum und Betrag. Komma/Punkt sowie eingefügte einfache Beträge mit Rand-Leerzeichen werden akzeptiert; Minus, Tausendertrenner, Währungszeichen, Überlauf und mehr als zwei Nachkommastellen abgewiesen, ohne Rundung. Dienste prüfen dieselben fachlichen Mindestregeln. Nach erneutem Rendern bleiben geänderte Eingaben erhalten. Prüfung: 35/35 Debug-Tests, darunter Parsing-Grenzfälle und gerenderte Formularprüfung mit Korrektur ungültiger Eingaben. Längere Buchungsdialoge können innerhalb des Fensters scrollen.
|
||||
|
||||
## U07 – Speichern und Mehrfachauslösung
|
||||
|
||||
Beide Buchungsdialoge sperren Formular und Aktionen während des Speicherns und zeigen „Wird gespeichert …“. Submit (einschließlich Enter) verwendet denselben geschützten Ablauf. Nach Erfolg meldet die Kontoseite Art und Betrag und hebt die betroffene Zeile kurz hervor. Transfererfassung liefert die eindeutige Quellbuchungs-ID zurück. Fehler halten Eingaben im geöffneten Dialog; ein fehlgeschlagener Seitenrefresh nach bereits bestätigtem Schreiben kann ausschließlich den Refresh wiederholen. Eine bewusste weitere Erfassung bleibt möglich. Optionale Serienerfassung wurde nicht hinzugefügt.
|
||||
|
||||
Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbuchung mit mehrfachen Submit-Ereignissen erzeugen jeweils genau eine fachliche Buchung; zweite bewusste Erfassung funktioniert. Simulierte Fehler behalten Beschreibung und Dezimaltext, erzeugen keine falsche Erfolgsmeldung und erlauben Retry. Gesonderter Test prüft Refreshfehler nach Commit ohne erneutes Schreiben. Jahresnavigation einschließlich „Alle Jahre“ ist ebenfalls gerendert geprüft.
|
||||
|
||||
## Abschluss und Grenzen
|
||||
|
||||
Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme.
|
||||
|
||||
## Nacharbeit vom 2. November – Eingabe, Toasts und Aktionsleiste
|
||||
|
||||
Beträge verwenden ein gemeinsames Control: Browser-beforeinput/Paste und die Komponentenbindung weisen mehr als zwei Dezimalstellen vollständig ab. Kein Kürzen oder Runden; der vorherige Wert bleibt erhalten. Der dauerhafte Nachkommastellen-Kommentar entfällt. Fortschritt, Dateiergebnisse und Speichermeldungen stehen in einem globalen Toast-Stapel oben rechts. Laufende Vorgänge bleiben bis zum Abschluss sichtbar; Ergebnisse laufen nach 9 Sekunden, Fehler nach 15 Sekunden ab. Hover/Fokus pausiert, Schließen bleibt möglich. Feldbezogene Validierung bleibt am Feld. Aktionsbuttons haben feste gemeinsame Höhe/Breite mit reservierter Symbolzeile und zwei Textzeilen.
|
||||
|
||||
Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separatem Debug-Datenordner: Übersicht und Konto geprüft; Eingabe `12,501` vollständig abgewiesen, `12,50` angenommen, künstliche Einnahme gespeichert, Kontostand von 88,30 auf 100,80 € erhöht und Toast oben rechts beobachtet. Stapeln, unabhängiges Ablaufen und Pausieren sind automatisiert geprüft. Keine Vereinsdaten geändert. Der zusätzliche Debug-Startparameter `--data-directory` isoliert Datenbank, Vereinsname und Fensterkonfiguration; Release erhält ihn nicht.
|
||||
|
||||
## Weiterer Umsetzungsplan
|
||||
|
||||
U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren.
|
||||
|
||||
## U08 – Tastatur und Modaldialoge
|
||||
|
||||
Gemeinsame native dialog-Komponente für Erfassung, Bestätigungen, Konten, Namen, Übertrag, Jahr und Über. showModal sperrt den Hintergrund und hält den Tastaturfokus im Dialog; Fokus geht ins sinnvolle Feld und nach Schließen zum Auslöser zurück. Escape schließt unveränderte Dialoge; geänderte Eingaben bieten Weiterbearbeiten/Verwerfen. Während Speichern kein Schließen. Unsichere Bestätigungen fokussieren Abbrechen und verwenden keine implizite Formularbestätigung. Kontokarten sind semantische Links. Labels sind technisch verbunden. Toasts bleiben auch innerhalb nativer Modaldialoge zugänglich. Prüfung: 44/44 Debug-Tests, warnungsfreier Build; Computer Use beobachtete Fokus im Beschreibungsfeld, ausschließlich modalen Accessibility-Baum und Escape-Schließen. Kein vollständiger Screenreader-Nutzertest.
|
||||
|
||||
## U09 – Zeilenaktionen
|
||||
|
||||
Beschriftetes Aktionsmenü je Buchung, sichtbares Bearbeiten/Löschen und Wiederherstellen als Wort. Zugängliche Namen nennen Nummer und Beschreibung, Trefferbereiche mindestens 44 CSS-Pixel. Auf schmalen Fenstern wechseln Zeilen in beschriftete Karten, damit die Aktionsspalte nicht die Beschreibung verdrängt. Gerenderter Test prüft Zuordnung und Aktion.
|
||||
|
||||
## U10 – Löschen und Rückweg
|
||||
|
||||
Gelöschte Zeilen sind standardmäßig ausgeblendet und gezählt erreichbar; sichtbare Historie erklärt die Nichtberücksichtigung im Kontostand. Bestätigungen nennen Datum, Beschreibung, Betrag, Konto und bei Umbuchungen beide Konten mit gemeinsamer Wirkung. Erfolg bietet gezieltes Rückgängig; Mehrfachauslösung wird gesperrt, bereits veränderter Zustand geprüft. Gerenderter Integrationstest löscht eine Umbuchung, prüft Gegenkonto, Historie und Rückgängig auf beiden Seiten.
|
||||
|
||||
## U11 – Umbuchungsrichtung
|
||||
|
||||
Beide Seiten zeigen dieselbe Richtung Von → Nach und erklären das feste aktuelle sowie änderbare Gegenkonto. Vorschau zeigt Minus/Plus und unveränderte Vereinssumme; nur ein Konto erklärt die fehlende Voraussetzung. Der Dienst weist beim Bearbeiten gleiche Konten und fehlende Gegenkonten vor jeder Änderung ab. Tests öffnen beide Seiten, ändern die Quelle einer empfangenen Umbuchung und prüfen den unveränderten Bestand nach unzulässiger Änderung.
|
||||
|
||||
## U12 – Anfangsbestand und Bestandskorrektur
|
||||
|
||||
Neue Konten erhalten einen erklärten Anfangsbestand ohne künstliche Einnahme. Spätere Korrektur zeigt Konto/Jahr, Formel, vorher/nachher für gewählten Bestand, gemeinsamen Anfangsbestand und Endstand sowie ausdrücklich die Auswirkungen auf frühere/spätere Jahre. Zustimmung ist erst nach Eingabe gültig, Abbrechen hat Anfangsfokus. Bestehende gemeinsame Semantik bleibt unverändert, keine Migration oder jahresbezogene neue Speicherregel. Dienst prüft den erwarteten Bestand in einer Transaktion und weist veraltete Vorschauen ab. Mehrjahrestest prüft die Verschiebung aller Jahre bei unveränderten Buchungen, Formularprüfung die notwendige Zustimmung.
|
||||
@@ -1,11 +1,11 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="card account-card h-100" @onclick="Navigate">
|
||||
<a class="card account-card h-100" href="@($"/accounts/{Account.Id}")" @onclick="Navigate" @onclick:preventDefault="true">
|
||||
<div class="card-body d-flex flex-column justify-content-between">
|
||||
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
|
||||
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
|
||||
@($"{Account.TotalBalance:N2} €")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<tr class="@GetRowClass()">
|
||||
<td>@Entry.DisplayId</td>
|
||||
<td>@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||
<td>
|
||||
<td data-label="Nummer">@Entry.DisplayId</td>
|
||||
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||
<td data-label="Beschreibung" class="entry-description">
|
||||
@Entry.Title
|
||||
@if (Entry.IsDeleted)
|
||||
{
|
||||
<p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p>
|
||||
}
|
||||
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
||||
{
|
||||
<small class="text-muted ms-1">
|
||||
@@ -10,26 +14,27 @@
|
||||
</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
||||
<td data-label="Betrag" class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
||||
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Aktionen" class="entry-actions">
|
||||
@if (Entry.IsDeleted)
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" title="Wiederherstellen">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
<button type="button" class="btn btn-outline-success" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} wiederherstellen")">
|
||||
<i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Wiederherstellen
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<details class="entry-action-menu">
|
||||
<summary aria-label="@($"Aktionen für {Entry.DisplayId} {Entry.Title}")">Aktionen</summary>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")">
|
||||
<i class="bi bi-pencil" aria-hidden="true"></i> Bearbeiten
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" title="Löschen">
|
||||
<i class="bi bi-trash"></i>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} löschen")">
|
||||
<i class="bi bi-trash" aria-hidden="true"></i> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryRow
|
||||
{
|
||||
[Parameter] public bool Highlighted { get; set; }
|
||||
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
@@ -26,6 +28,7 @@ public partial class EntryRow
|
||||
private string GetRowClass()
|
||||
{
|
||||
var classes = new List<string>();
|
||||
if (Highlighted) classes.Add("entry-recent");
|
||||
if (Entry.IsTransfer)
|
||||
{
|
||||
classes.Add("entry-row-transfer");
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<div class="table-responsive">
|
||||
@if (Entries.Any(e => e.IsDeleted))
|
||||
{
|
||||
<label class="history-toggle"><input type="checkbox" @bind="showDeleted" /> Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label>
|
||||
}
|
||||
<div class="table-responsive entry-list">
|
||||
<table class="table table-hover table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
@@ -6,20 +10,20 @@
|
||||
<th style="width: 120px;">Datum</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th style="width: 140px;" class="text-end">Betrag</th>
|
||||
<th style="width: 80px;"></th>
|
||||
<th style="width: 11rem;">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in Entries)
|
||||
@foreach (var entry in VisibleEntries)
|
||||
{
|
||||
<EntryRow Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!Entries.Any())
|
||||
@if (!VisibleEntries.Any())
|
||||
{
|
||||
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div>
|
||||
<div class="text-center py-3 text-muted">@EmptyMessage</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryTable
|
||||
{
|
||||
private bool showDeleted;
|
||||
private IEnumerable<EntryDto> VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted);
|
||||
[Parameter] public int? HighlightedEntryId { get; set; }
|
||||
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
|
||||
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
|
||||
aria-describedby="@(Id + "-rejected")" />
|
||||
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "amount";
|
||||
[Parameter] public string Value { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNegative { get; set; }
|
||||
private string? rejection;
|
||||
private async Task SetValue(string value)
|
||||
{
|
||||
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||
{
|
||||
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
|
||||
return;
|
||||
}
|
||||
rejection = null;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
@inject ToastService Toasts
|
||||
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
|
||||
<h5 class="mb-3">Neues Konto</h5>
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||
<div class="form-container">
|
||||
<label class="form-label">Kontoname</label>
|
||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||
<label class="form-label" for="account-name">Kontoname</label>
|
||||
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||
placeholder="z.B. Girokonto" />
|
||||
</div>
|
||||
<label for="account-initial">Anfangsbestand (€)</label>
|
||||
<AmountInput Id="account-initial" @bind-Value="initialAmount" AllowNegative="true" Disabled="saving" />
|
||||
<p>Geld, das vor der ersten erfassten Buchung vorhanden ist. 0 ist möglich, ein Minus bei überzogenem Konto ebenfalls. Der Anfangsbestand zählt nicht als neue Einnahme.</p>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success" disabled="@(saving || string.IsNullOrWhiteSpace(name) || !ValidInitial)"><i class="bi bi-check-lg"></i> Erstellen</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> OnSave { get; set; }
|
||||
public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
|
||||
#region Fields
|
||||
|
||||
private string name = string.Empty;
|
||||
private string initialAmount = "0,00";
|
||||
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
|
||||
private bool saving;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -24,13 +27,16 @@ public partial class AddAccountDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
if (!saving && !string.IsNullOrWhiteSpace(name) && ValidInitial)
|
||||
{
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
saving = true;
|
||||
try { Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out var initial); await OnSave.InvokeAsync(new(name.Trim(), initial)); }
|
||||
catch (Exception ex) { Toasts.Show("Das Konto konnte nicht angelegt werden. Die Eingaben bleiben erhalten.", Duempelkas.App.Services.ToastKind.Error, ex.Message); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,36 +1,62 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
|
||||
<ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
|
||||
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
|
||||
|
||||
<div class="form-container">
|
||||
@if (EditEntry == null)
|
||||
{
|
||||
<label class="form-label">Art</label>
|
||||
<select @bind="entryType">
|
||||
<option value="@EntryType.Income">Einnahme</option>
|
||||
<option value="@EntryType.Expense">Ausgabe</option>
|
||||
</select>
|
||||
}
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||
<div class="form-container">
|
||||
<label class="form-label" for="entry-type">Art</label>
|
||||
@if (EditEntry == null)
|
||||
{
|
||||
<select id="entry-type" @bind="entryType">
|
||||
<option value="@EntryType.Income">Einnahme</option>
|
||||
<option value="@EntryType.Expense">Ausgabe</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div><strong>@(entryType == EntryType.Income ? "Einnahme" : "Ausgabe")</strong>
|
||||
<p class="text-muted">Falsche Art? Diese Buchung abbrechen, in der Kontoliste löschen und mit der richtigen Art neu erfassen.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<input type="date" @bind="date" />
|
||||
<label class="form-label" for="entry-date">Datum</label>
|
||||
<div>
|
||||
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" />
|
||||
@if (date == default) { <p id="entry-date-help" class="text-danger">Bitte ein gültiges Datum eingeben.</p> }
|
||||
</div>
|
||||
|
||||
<label class="form-label">Bezeichnung</label>
|
||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
|
||||
<label class="form-label" for="entry-title">Beschreibung</label>
|
||||
<div>
|
||||
<input id="entry-title" data-initial-focus type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" />
|
||||
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
|
||||
</div>
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
||||
</div>
|
||||
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||
<div>
|
||||
<AmountInput Id="entry-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"
|
||||
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
|
||||
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (OutsideYear && date != default)
|
||||
{
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -15,19 +15,41 @@ public partial class AddEntryDialog
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
[Parameter] public int? ContextYear { get; set; }
|
||||
private bool confirmDifferentYear;
|
||||
private int? confirmedYear;
|
||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
||||
|
||||
[Parameter] public string AccountName { get; set; } = string.Empty;
|
||||
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||
|
||||
private bool isSaving;
|
||||
private bool completed;
|
||||
private BookingSaved? committedResult;
|
||||
private string? saveError;
|
||||
private string? saveDetails;
|
||||
|
||||
#region Fields
|
||||
|
||||
private EntryType entryType = EntryType.Income;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
|
||||
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
private string amountText = string.Empty;
|
||||
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
|
||||
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
|
||||
private string initialState = string.Empty;
|
||||
private string CurrentState => $"{dateText}|{title}|{amountText}|{entryType}";
|
||||
private bool IsDirty => CurrentState != initialState;
|
||||
private bool initialized;
|
||||
private int? initializedEntryId;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -35,13 +57,17 @@ public partial class AddEntryDialog
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||
initialized = true;
|
||||
initializedEntryId = EditEntry?.Id;
|
||||
if (EditEntry != null)
|
||||
{
|
||||
entryType = EditEntry.Type;
|
||||
date = EditEntry.Date;
|
||||
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
initialState = CurrentState;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -50,19 +76,42 @@ public partial class AddEntryDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
try
|
||||
{
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
if (committedResult == null)
|
||||
{
|
||||
var id = EditEntry?.Id ?? 0;
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
else
|
||||
id = (await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount)).Id;
|
||||
committedResult = new(id, date, entryType == EntryType.Income ? "Einnahme" : "Ausgabe", amount, EditEntry != null);
|
||||
}
|
||||
// If refreshing the page fails after commit, retry only notification,
|
||||
// never create a second booking.
|
||||
await OnSave.InvokeAsync(committedResult);
|
||||
completed = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
|
||||
saveError = committedResult == null
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
|
||||
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
||||
|
||||
<div class="form-container">
|
||||
<label class="form-label">Zielkonto</label>
|
||||
<select class="form-select" @bind="targetAccountId">
|
||||
<option value="0">— Auswählen —</option>
|
||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
||||
<p id="transfer-direction"><strong>Von: @FromName → Nach: @ToName</strong></p>
|
||||
<p>@CurrentAccountName bleibt fest. @(IsIncoming ? "Das abgebende Konto ist änderbar." : "Das empfangende Konto ist änderbar.")</p>
|
||||
@if (accounts.Count < 2)
|
||||
{
|
||||
<p role="status">Für eine Umbuchung brauchst du ein zweites Konto. Lege es in der Kontenübersicht an.</p>
|
||||
}
|
||||
@if (ValidAmount && targetAccountId > 0)
|
||||
{
|
||||
<p role="status">@FromName −@Money(amount), @ToName +@Money(amount); Gesamtsumme unverändert.</p>
|
||||
}
|
||||
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||
<div class="form-container">
|
||||
<label class="form-label" for="transfer-account">@(IsIncoming ? "Von Konto (änderbar)" : "Nach Konto (änderbar)")</label>
|
||||
<select id="transfer-account" class="form-select" @bind="targetAccountId">
|
||||
<option value="0">— Auswählen —</option>
|
||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||
{
|
||||
<option value="@acc.Id">@acc.Name</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="form-label" for="transfer-date">Datum</label>
|
||||
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
|
||||
|
||||
<label class="form-label" for="transfer-title">Bezeichnung</label>
|
||||
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label" for="transfer-amount">Betrag (€)</label>
|
||||
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear)
|
||||
{
|
||||
<option value="@acc.Id">@acc.Name</option>
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
|
||||
</div>
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<input type="date" class="form-control" @bind="date" />
|
||||
|
||||
<label class="form-label">Bezeichnung</label>
|
||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"
|
||||
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -14,26 +14,52 @@ public partial class AddTransferDialog
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
[Parameter] public int? ContextYear { get; set; }
|
||||
private bool confirmDifferentYear;
|
||||
private int? confirmedYear;
|
||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
||||
|
||||
private bool isSaving;
|
||||
private bool completed;
|
||||
private BookingSaved? committedResult;
|
||||
private string? saveError;
|
||||
private string? saveDetails;
|
||||
|
||||
#region Fields
|
||||
|
||||
private List<AccountSummaryDto> accounts = new();
|
||||
private int targetAccountId;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
|
||||
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
private string amountText = string.Empty;
|
||||
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
|
||||
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
|
||||
private string initialState = string.Empty;
|
||||
private string CurrentState => $"{dateText}|{title}|{amountText}|{targetAccountId}";
|
||||
private bool IsDirty => CurrentState != initialState;
|
||||
private bool initialized;
|
||||
private int? initializedEntryId;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
|
||||
private bool IsIncoming => EditEntry?.Type == Duempelkas.Domain.Enums.EntryType.Income;
|
||||
private string CurrentAccountName => accounts.FirstOrDefault(a => a.Id == SourceAccountId)?.Name ?? "Aktuelles Konto";
|
||||
private string LinkedAccountName => accounts.FirstOrDefault(a => a.Id == targetAccountId)?.Name ?? "Konto auswählen";
|
||||
private string FromName => IsIncoming ? LinkedAccountName : CurrentAccountName;
|
||||
private string ToName => IsIncoming ? CurrentAccountName : LinkedAccountName;
|
||||
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -46,13 +72,17 @@ public partial class AddTransferDialog
|
||||
accounts = await AccountService.GetAllAccountsAsync();
|
||||
}
|
||||
|
||||
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||
initialized = true;
|
||||
initializedEntryId = EditEntry?.Id;
|
||||
if (EditEntry != null)
|
||||
{
|
||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||
date = EditEntry.Date;
|
||||
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
initialState = CurrentState;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -61,19 +91,42 @@ public partial class AddTransferDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
try
|
||||
{
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
if (committedResult == null)
|
||||
{
|
||||
var id = EditEntry?.Id ?? 0;
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
else
|
||||
id = await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
committedResult = new(id, date, "Umbuchung", amount, EditEntry != null);
|
||||
}
|
||||
// If refreshing the page fails after commit, retry only notification,
|
||||
// never create a second booking.
|
||||
await OnSave.InvokeAsync(committedResult);
|
||||
completed = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
saveError = committedResult == null
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" Busy="Busy">
|
||||
<h5>@Title</h5>
|
||||
<p>@Message</p>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||
<button type="button" disabled="@Busy" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
||||
<button type="button" disabled="@Busy" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class ConfirmDialog
|
||||
{
|
||||
[Parameter] public bool Busy { get; set; }
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
@@ -34,9 +35,9 @@ public partial class ConfirmDialog
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Confirm() => await OnConfirm.InvokeAsync();
|
||||
private async Task Confirm() { if (!Busy) await OnConfirm.InvokeAsync(); }
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
||||
<div class="form-container">
|
||||
<label class="form-label">Übertrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
@inject ToastService Toasts
|
||||
<ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)" Busy="saving">
|
||||
<h5>Bestandskorrektur – @AccountName</h5>
|
||||
<p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
|
||||
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
|
||||
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
|
||||
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
|
||||
<AmountInput Id="carryover" Value="amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
|
||||
<div class="correction-preview">
|
||||
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
|
||||
<p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p>
|
||||
<p>Kontostand der Ansicht: @Money(CurrentEndBalance) → @Money(CurrentEndBalance + Delta)</p>
|
||||
<p><strong>Alle Kontostände dieses Kontos ändern sich um @Money(Delta). Das betrifft frühere und spätere Jahre.</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label><input type="checkbox" @bind="acknowledged" disabled="@saving" /> Ich habe die Auswirkungen auf alle Jahre geprüft.</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-success" disabled="@(saving || !ValidAmount || !acknowledged || Delta == 0m)" @onclick="Save">Korrektur speichern</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@saving" data-safe-focus @onclick="Cancel">Abbrechen</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using Duempelkas.App.Services;
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class EditCarryoverDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public decimal CurrentAmount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<decimal> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private decimal amount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => amount = CurrentAmount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save() => await OnSave.InvokeAsync(amount);
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
[Parameter] public decimal CurrentAmount { get; set; }
|
||||
[Parameter] public decimal InitialBalance { get; set; }
|
||||
[Parameter] public decimal CurrentEndBalance { get; set; }
|
||||
[Parameter] public int? Year { get; set; }
|
||||
[Parameter] public string AccountName { get; set; } = "Konto";
|
||||
[Parameter] public EventCallback<decimal> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
private string amountText = string.Empty;
|
||||
private bool acknowledged;
|
||||
private bool initialized;
|
||||
private bool saving;
|
||||
private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
|
||||
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
|
||||
private decimal Delta => amount - CurrentAmount;
|
||||
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
|
||||
}
|
||||
private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
|
||||
private async Task Save()
|
||||
{
|
||||
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
|
||||
saving = true;
|
||||
try { await OnSave.InvokeAsync(amount); }
|
||||
catch (Exception ex) { Toasts.Show("Die Korrektur konnte nicht gespeichert werden. Bitte den aktuellen Bestand prüfen.", ToastKind.Error, ex.Message); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
|
||||
<h5 class="mb-3">@DialogTitle</h5>
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||
<div class="form-container">
|
||||
<label class="form-label">@NameLabel</label>
|
||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||
<label class="form-label" for="edit-name">@NameLabel</label>
|
||||
<input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -31,7 +31,8 @@ public partial class EditNameDialog
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => name = CurrentName;
|
||||
private bool initialized;
|
||||
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -45,7 +46,7 @@ public partial class EditNameDialog
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<dialog @ref="element" class="dialog-content app-modal" aria-labelledby="@titleId">
|
||||
<ToastHost InDialog="true" />
|
||||
<h2 id="@titleId" class="visually-hidden">@Title</h2>
|
||||
<div inert="@discardPrompt">@ChildContent</div>
|
||||
@if (discardPrompt)
|
||||
{
|
||||
<section role="alertdialog" aria-label="Ungespeicherte Eingaben">
|
||||
<h3>Ungespeicherte Eingaben</h3>
|
||||
<p>Die Änderungen wurden noch nicht gespeichert. Möchtest du weiter bearbeiten oder die Eingaben verwerfen?</p>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-primary" data-discard-continue @onclick="ContinueEditing">Weiter bearbeiten</button>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="Discard">Eingaben verwerfen</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</dialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Title { get; set; } = "Dialog";
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public bool Dirty { get; set; }
|
||||
[Parameter] public bool Busy { get; set; }
|
||||
private readonly string titleId = "dialog-" + Guid.NewGuid().ToString("N");
|
||||
private ElementReference element;
|
||||
private DotNetObjectReference<ModalDialog>? reference;
|
||||
private bool discardPrompt;
|
||||
private bool focusPrompt;
|
||||
private bool disposed;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("duempelkasDialog.open", element, reference);
|
||||
}
|
||||
if (focusPrompt)
|
||||
{
|
||||
focusPrompt = false;
|
||||
await JS.InvokeVoidAsync("duempelkasDialog.focus", element, discardPrompt ? "[data-discard-continue]" : "[data-initial-focus],input,select,textarea,button");
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task RequestCloseAsync()
|
||||
{
|
||||
if (Busy || disposed) return;
|
||||
if (Dirty && !discardPrompt)
|
||||
{
|
||||
discardPrompt = true;
|
||||
focusPrompt = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
else if (discardPrompt) await ContinueEditing();
|
||||
else await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
private Task ContinueEditing()
|
||||
{
|
||||
discardPrompt = false;
|
||||
focusPrompt = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
private Task Discard() => OnCancel.InvokeAsync();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
disposed = true;
|
||||
try { await JS.InvokeVoidAsync("duempelkasDialog.close", element); }
|
||||
catch (Exception ex) when (ex is JSException or JSDisconnectedException or ObjectDisposedException or TaskCanceledException) { }
|
||||
reference?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
<a class="navbar-brand" href="">
|
||||
@@ -18,8 +20,7 @@
|
||||
|
||||
@if (showAboutDialog)
|
||||
{
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
|
||||
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
||||
<p class="mb-2">Entwickler: Andre Beging</p>
|
||||
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
|
||||
@@ -28,7 +29,6 @@
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IFileSaveService FileSaveService
|
||||
@code {
|
||||
[Parameter] public FileOperationResult? Result { get; set; }
|
||||
private FileOperationResult? previous;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Result == null || ReferenceEquals(previous, Result)) return;
|
||||
previous = Result;
|
||||
var result = Result;
|
||||
Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""),
|
||||
result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info,
|
||||
result.Details, result.Path != null ? "Ordner öffnen" : null,
|
||||
result.Path != null ? async () => {
|
||||
var opened = await FileSaveService.OpenFolderAsync(result.Path);
|
||||
Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details);
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@implements IDisposable
|
||||
@inject ToastService Toasts
|
||||
|
||||
<div class="toast-stack @(InDialog ? "modal-toasts" : "")" aria-label="Meldungen">
|
||||
@foreach (var toast in Toasts.Messages)
|
||||
{
|
||||
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
||||
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
|
||||
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
|
||||
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
|
||||
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
|
||||
<p>@toast.Message</p>
|
||||
@if (toast.Action != null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
|
||||
}
|
||||
@if (toast.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public bool InDialog { get; set; }
|
||||
private readonly HashSet<Guid> running = [];
|
||||
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
||||
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
||||
private async Task RunAction(ToastMessage toast)
|
||||
{
|
||||
if (!running.Add(toast.Id)) return;
|
||||
Toasts.Pause(toast.Id);
|
||||
try { await toast.Action!(); }
|
||||
catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); }
|
||||
}
|
||||
public void Dispose() => Toasts.Changed -= Refresh;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/accounts/{AccountId:int}"
|
||||
@inject IAccountService AccountService
|
||||
@inject IEntryService EntryService
|
||||
@@ -7,6 +9,8 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
|
||||
@if (account == null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
@@ -24,32 +28,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
<i class="bi bi-arrow-left"></i><span>Zurück</span>
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
|
||||
<i class="bi bi-plus-lg"></i> Buchung
|
||||
<i class="bi bi-plus-lg"></i> <span>Einnahme oder<br /> Ausgabe erfassen</span>
|
||||
</button>
|
||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
||||
<i class="bi bi-arrow-left-right"></i> Umbuchung
|
||||
<i class="bi bi-arrow-left-right"></i> <span>Geld zwischen<br /> Konten verschieben</span>
|
||||
</button>
|
||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
|
||||
@if (balance != null)
|
||||
{
|
||||
<div class="summary-section">
|
||||
<div class="summary-flex">
|
||||
<div>
|
||||
<small class="text-muted">Saldo</small>
|
||||
<small class="text-muted">Kontostand</small>
|
||||
<div class="fs-3 fw-bold @(balance.TotalBalance >= 0 ? "amount-positive" : "amount-negative")">
|
||||
@FormatAmount(balance.TotalBalance)
|
||||
</div>
|
||||
@@ -79,7 +82,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Umsätze @selectedYear.Value</small>
|
||||
<small class="text-muted">Änderung im Jahr @selectedYear.Value</small>
|
||||
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
|
||||
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
|
||||
</div>
|
||||
@@ -93,7 +96,7 @@
|
||||
@if (entries != null)
|
||||
{
|
||||
<div class="entries-scroll-wrapper">
|
||||
<EntryTable Entries="entries" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -101,14 +104,14 @@
|
||||
|
||||
@if (showAddEntry)
|
||||
{
|
||||
<AddEntryDialog AccountId="AccountId"
|
||||
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||
OnSave="HandleEntryCreated"
|
||||
OnCancel="() => showAddEntry = false" />
|
||||
}
|
||||
|
||||
@if (showAddTransfer)
|
||||
{
|
||||
<AddTransferDialog SourceAccountId="AccountId"
|
||||
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||
OnSave="HandleTransferCreated"
|
||||
OnCancel="() => showAddTransfer = false" />
|
||||
}
|
||||
@@ -122,7 +125,7 @@
|
||||
|
||||
@if (showEditCarryover)
|
||||
{
|
||||
<EditCarryoverDialog CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
|
||||
<EditCarryoverDialog AccountName="@(account?.Name ?? "")" Year="selectedYear" InitialBalance="@(account?.CarryoverBalance ?? 0)" CurrentEndBalance="@(balance?.TotalBalance ?? 0)" CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
|
||||
OnSave="HandleSaveCarryover"
|
||||
OnCancel="() => showEditCarryover = false" />
|
||||
}
|
||||
@@ -130,7 +133,7 @@
|
||||
@if (confirmDeleteEntryId.HasValue)
|
||||
{
|
||||
<ConfirmDialog Title="Buchung löschen"
|
||||
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")"
|
||||
Message="@(EntryChangeMessage(confirmDeleteEntryId, false))" Busy="entryActionBusy"
|
||||
ConfirmText="Ja, löschen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-danger"
|
||||
@@ -142,7 +145,7 @@
|
||||
@if (confirmRestoreEntryId.HasValue)
|
||||
{
|
||||
<ConfirmDialog Title="Buchung wiederherstellen"
|
||||
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")"
|
||||
Message="@(EntryChangeMessage(confirmRestoreEntryId, true))" Busy="entryActionBusy"
|
||||
ConfirmText="Ja, wiederherstellen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-warning"
|
||||
@@ -154,7 +157,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
@@ -165,7 +168,7 @@
|
||||
|
||||
@if (editingEntry != null)
|
||||
{
|
||||
<AddEntryDialog AccountId="AccountId"
|
||||
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||
EditEntry="editingEntry"
|
||||
OnSave="HandleEntryEdited"
|
||||
OnCancel="() => editingEntry = null" />
|
||||
@@ -173,7 +176,7 @@
|
||||
|
||||
@if (editingTransferEntry != null)
|
||||
{
|
||||
<AddTransferDialog SourceAccountId="AccountId"
|
||||
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||
EditEntry="editingTransferEntry"
|
||||
OnSave="HandleEntryEdited"
|
||||
OnCancel="() => editingTransferEntry = null" />
|
||||
@@ -181,8 +184,7 @@
|
||||
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
@@ -201,7 +203,6 @@
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -10,6 +11,10 @@ public partial class AccountDetail
|
||||
[Parameter]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "jahr")]
|
||||
public int? QueryYear { get; set; }
|
||||
private int? previousQueryYear;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
@@ -22,9 +27,11 @@ public partial class AccountDetail
|
||||
private bool showEditName;
|
||||
private bool showEditCarryover;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||
private List<int> availableYears = [];
|
||||
|
||||
private bool entryActionBusy;
|
||||
private Guid? undoToastId;
|
||||
private int? confirmDeleteEntryId;
|
||||
private string? confirmDeleteEntryTitle;
|
||||
|
||||
@@ -32,7 +39,12 @@ public partial class AccountDetail
|
||||
private string? confirmRestoreEntryTitle;
|
||||
|
||||
private string? savedPdfPath;
|
||||
private FileOperationResult? fileResult;
|
||||
private bool fileBusy;
|
||||
|
||||
private int? savedOutsideYear;
|
||||
private BookingSaved? lastSaved;
|
||||
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
|
||||
private EntryDto? editingEntry;
|
||||
private EntryDto? editingTransferEntry;
|
||||
|
||||
@@ -48,6 +60,8 @@ public partial class AccountDetail
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (QueryYear.HasValue && QueryYear != previousQueryYear) selectedYear = QueryYear;
|
||||
previousQueryYear = QueryYear;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
@@ -60,7 +74,9 @@ public partial class AccountDetail
|
||||
account = await AccountService.GetAccountAsync(AccountId);
|
||||
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
|
||||
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
|
||||
availableYears = await EntryService.GetEntryYearsAsync(AccountId);
|
||||
var entryYears = await EntryService.GetEntryYearsAsync(AccountId);
|
||||
availableYears = Period.Choices(entryYears);
|
||||
emptyEntriesMessage = entryYears.Count == 0 ? "Noch keine Buchungen vorhanden." : "Keine Buchungen in diesem Zeitraum. Wähle ein anderes Jahr oder Alle Jahre.";
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
@@ -76,6 +92,7 @@ public partial class AccountDetail
|
||||
private async Task SelectFilterYear(int? year)
|
||||
{
|
||||
selectedYear = year;
|
||||
savedOutsideYear = null;
|
||||
showYearFilterDialog = false;
|
||||
await LoadAll();
|
||||
}
|
||||
@@ -93,22 +110,34 @@ public partial class AccountDetail
|
||||
|
||||
private async Task HandleSaveCarryover(decimal newAmount)
|
||||
{
|
||||
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
|
||||
await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance);
|
||||
await LoadAll();
|
||||
showEditCarryover = false;
|
||||
await LoadAll();
|
||||
Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success);
|
||||
}
|
||||
|
||||
private async Task HandleEntryCreated()
|
||||
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private Task HandleTransferCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private async Task HandleBookingSaved(BookingSaved saved)
|
||||
{
|
||||
await LoadAll();
|
||||
lastSaved = saved;
|
||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null;
|
||||
var nav = Navigation;
|
||||
var period = Period;
|
||||
var accountId = AccountId;
|
||||
Toasts.Show($"{saved.Kind} über {FormatAmount(saved.Amount)} {(saved.Edited ? "aktualisiert" : "gespeichert")}.", ToastKind.Success,
|
||||
actionLabel: savedOutsideYear.HasValue ? $"Buchung im Jahr {saved.Date.Year} anzeigen" : null,
|
||||
action: savedOutsideYear.HasValue ? () => { period.Year = saved.Date.Year; nav.NavigateTo($"/accounts/{accountId}?jahr={saved.Date.Year}"); return Task.CompletedTask; } : null);
|
||||
showAddEntry = false;
|
||||
await LoadAll();
|
||||
showAddTransfer = false;
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
}
|
||||
|
||||
private async Task HandleTransferCreated()
|
||||
{
|
||||
showAddTransfer = false;
|
||||
await LoadAll();
|
||||
}
|
||||
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
|
||||
|
||||
private void RequestDeleteEntry(int entryId)
|
||||
{
|
||||
@@ -118,19 +147,27 @@ public partial class AccountDetail
|
||||
|
||||
private void CancelDeleteConfirm()
|
||||
{
|
||||
if (entryActionBusy) return;
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDelete()
|
||||
{
|
||||
if (confirmDeleteEntryId.HasValue)
|
||||
if (entryActionBusy || !confirmDeleteEntryId.HasValue) return;
|
||||
entryActionBusy = true;
|
||||
try
|
||||
{
|
||||
await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value);
|
||||
var id = confirmDeleteEntryId.Value;
|
||||
await EntryService.DeleteEntryAsync(id);
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
await LoadAll();
|
||||
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||
undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success,
|
||||
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, true));
|
||||
}
|
||||
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { entryActionBusy = false; }
|
||||
}
|
||||
|
||||
private void RequestRestoreEntry(int entryId)
|
||||
@@ -141,19 +178,57 @@ public partial class AccountDetail
|
||||
|
||||
private void CancelRestoreConfirm()
|
||||
{
|
||||
if (entryActionBusy) return;
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (confirmRestoreEntryId.HasValue)
|
||||
if (entryActionBusy || !confirmRestoreEntryId.HasValue) return;
|
||||
entryActionBusy = true;
|
||||
try
|
||||
{
|
||||
await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value);
|
||||
var id = confirmRestoreEntryId.Value;
|
||||
await EntryService.RestoreEntryAsync(id);
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
await LoadAll();
|
||||
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||
undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success,
|
||||
actionLabel: "Rückgängig", action: () => UndoEntryChange(id, false));
|
||||
}
|
||||
catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { entryActionBusy = false; }
|
||||
}
|
||||
|
||||
private async Task UndoEntryChange(int id, bool restore)
|
||||
{
|
||||
if (entryActionBusy) return;
|
||||
entryActionBusy = true;
|
||||
try
|
||||
{
|
||||
var current = (await EntryService.GetEntriesAsync(AccountId, (int?)null)).FirstOrDefault(e => e.Id == id);
|
||||
if (current == null || current.IsDeleted != restore)
|
||||
{
|
||||
Toasts.Show("Die Buchung wurde inzwischen geändert. Bitte die aktuelle Liste prüfen.");
|
||||
return;
|
||||
}
|
||||
if (restore) await EntryService.RestoreEntryAsync(id); else await EntryService.DeleteEntryAsync(id);
|
||||
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||
await LoadAll();
|
||||
Toasts.Show("Änderung rückgängig gemacht.", ToastKind.Success);
|
||||
}
|
||||
finally { entryActionBusy = false; }
|
||||
}
|
||||
|
||||
private string EntryChangeMessage(int? id, bool restore)
|
||||
{
|
||||
var entry = entries?.FirstOrDefault(e => e.Id == id);
|
||||
if (entry == null) return "Bitte die aktuelle Buchung prüfen.";
|
||||
var description = $"{entry.Date:dd.MM.yyyy}, {entry.Title}, {FormatAmount(entry.Amount)}, Konto {account?.Name}.";
|
||||
return description + (entry.IsTransfer
|
||||
? $" Diese Umbuchung betrifft {account?.Name} und {entry.LinkedAccountName}. Die Buchungen auf beiden Konten werden gemeinsam {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet")}."
|
||||
: $" Die Buchung wird {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet") }.");
|
||||
}
|
||||
|
||||
private void RequestEditEntry(int entryId)
|
||||
@@ -169,28 +244,45 @@ public partial class AccountDetail
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEntryEdited()
|
||||
{
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
await LoadAll();
|
||||
}
|
||||
private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
|
||||
private async Task HandleExport()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
||||
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
||||
|
||||
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
@@ -202,9 +294,9 @@ public partial class AccountDetail
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
|
||||
private static string FormatAmount(decimal amount) => amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
private string GetFilterLabel() => Period.Label;
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/"
|
||||
@inject IAccountService AccountService
|
||||
@inject IBackupService BackupService
|
||||
@@ -8,6 +10,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
||||
@@ -20,46 +24,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
|
||||
<i class="bi bi-plus-lg"></i> Neues Konto
|
||||
<i class="bi bi-plus-lg"></i><span>Neues Konto</span>
|
||||
</button>
|
||||
|
|
||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
|
||||
<i class="bi bi-save"></i> Backup
|
||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-save"></i> <span>Sicherung<br /> erstellen</span>
|
||||
</button>
|
||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
|
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
||||
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
|
||||
<i class="@ThemeButtonIconClass"></i><span>Modus<br />@ThemeButtonLabel</span>
|
||||
</button>
|
||||
<details class="more-actions">
|
||||
<summary class="btn btn-nav btn-outline-secondary"><i class="bi bi-three-dots"></i><span>Weitere<br /> Aktionen</span></summary>
|
||||
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@if (accounts != null && accounts.Any())
|
||||
{
|
||||
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
||||
<strong>Summe aller Konten:</strong>
|
||||
<strong>Summe aller Konten – @Period.Label:</strong>
|
||||
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||
@FormatCurrency(TotalClubBalance)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
||||
{
|
||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
||||
@operationMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (accounts == null)
|
||||
{
|
||||
@@ -94,7 +93,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
@@ -105,8 +104,8 @@
|
||||
|
||||
@if (showRestoreConfirm)
|
||||
{
|
||||
<ConfirmDialog Title="Restore bestätigen"
|
||||
Message="Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?"
|
||||
<ConfirmDialog Title="Sicherung zurückspielen"
|
||||
Message="Die Buchungen und Konten werden durch die gewählte Sicherung ersetzt. Vorher wird eine Rückfallsicherung erstellt. Vereinsname und Darstellung sind nicht enthalten. Möchtest du fortfahren?"
|
||||
ConfirmText="Ja, wiederherstellen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-warning"
|
||||
@@ -117,8 +116,7 @@
|
||||
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
@@ -137,7 +135,6 @@
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ public partial class Dashboard
|
||||
private bool showEditClubName;
|
||||
private bool showRestoreConfirm;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||
private List<int> availableYears = [];
|
||||
private string clubName = string.Empty;
|
||||
private string? operationMessage;
|
||||
private string operationMessageClass = "alert-info";
|
||||
private string? savedPdfPath;
|
||||
private FileOperationResult? fileResult;
|
||||
private bool fileBusy;
|
||||
private string currentTheme = "light";
|
||||
|
||||
#endregion
|
||||
@@ -67,7 +67,7 @@ public partial class Dashboard
|
||||
private async Task LoadAll()
|
||||
{
|
||||
await LoadAccounts();
|
||||
availableYears = await EntryService.GetAllEntryYearsAsync();
|
||||
availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
@@ -84,9 +84,9 @@ public partial class Dashboard
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task HandleAccountCreated(string name)
|
||||
private async Task HandleAccountCreated(AccountSetup setup)
|
||||
{
|
||||
await AccountService.CreateAccountAsync(name);
|
||||
await AccountService.CreateAccountAsync(setup.Name, setup.InitialBalance);
|
||||
showAddAccount = false;
|
||||
await LoadAll();
|
||||
}
|
||||
@@ -100,11 +100,17 @@ public partial class Dashboard
|
||||
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
var message = await BackupService.CreateBackupAsync();
|
||||
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
SetOperationMessage(message, false);
|
||||
fileResult = await BackupService.CreateBackupAsync();
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private Task HandleRestoreAsync()
|
||||
@@ -120,25 +126,38 @@ public partial class Dashboard
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
showRestoreConfirm = false;
|
||||
var message = await BackupService.RestoreBackupAsync();
|
||||
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
showRestoreConfirm = false;
|
||||
fileResult = await BackupService.RestoreBackupAsync();
|
||||
if (fileResult.Status == FileOperationStatus.Success) await LoadAll();
|
||||
|
||||
if (isSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetOperationMessage(message, isSuccess);
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
|
||||
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
|
||||
|
||||
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
@@ -160,12 +179,22 @@ public partial class Dashboard
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
@@ -177,18 +206,12 @@ public partial class Dashboard
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void SetOperationMessage(string message, bool success)
|
||||
{
|
||||
operationMessage = message;
|
||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
||||
}
|
||||
|
||||
private static string FormatCurrency(decimal amount)
|
||||
{
|
||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
}
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
private string GetFilterLabel() => Period.Label;
|
||||
|
||||
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public static partial class BookingInput
|
||||
{
|
||||
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AmountPattern();
|
||||
|
||||
public static bool IsAmountDraft(string value, bool allowNegative = false) =>
|
||||
Regex.IsMatch(value, allowNegative ? @"^-?\d*([,.]\d{0,2})?$" : @"^\d*([,.]\d{0,2})?$", RegexOptions.CultureInvariant);
|
||||
|
||||
public static bool TryAmount(string text, out decimal amount)
|
||||
{
|
||||
amount = 0;
|
||||
var plain = text.Trim();
|
||||
return AmountPattern().IsMatch(plain)
|
||||
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)
|
||||
&& amount > 0;
|
||||
}
|
||||
|
||||
public static bool TryBalance(string text, out decimal amount)
|
||||
{
|
||||
amount = 0;
|
||||
var plain = text.Trim();
|
||||
return Regex.IsMatch(plain, @"^-?\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)
|
||||
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out amount);
|
||||
}
|
||||
|
||||
public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
|
||||
|
||||
public static void Validate(DateTime date, string title, decimal amount)
|
||||
{
|
||||
if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben.");
|
||||
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben.");
|
||||
if (amount <= 0 || decimal.Round(amount, 2) != amount)
|
||||
throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben.");
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ public interface IAccountService
|
||||
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
|
||||
Task<AccountSummaryDto> GetAccountAsync(int accountId);
|
||||
Task<AccountSummaryDto> CreateAccountAsync(string name);
|
||||
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
|
||||
Task RenameAccountAsync(int accountId, string newName);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
|
||||
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
|
||||
Task DeleteAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IBackupService
|
||||
{
|
||||
Task<string> CreateBackupAsync();
|
||||
Task<string> RestoreBackupAsync();
|
||||
Task<FileOperationResult> CreateBackupAsync();
|
||||
Task<FileOperationResult> RestoreBackupAsync();
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public interface IEntryService
|
||||
Task<List<int>> GetEntryYearsAsync(int accountId);
|
||||
Task<List<int>> GetAllEntryYearsAsync();
|
||||
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
||||
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||
Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||
Task DeleteEntryAsync(int entryId);
|
||||
Task RestoreEntryAsync(int entryId);
|
||||
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IFileDialogService
|
||||
{
|
||||
Task<string?> SaveAsync(string suggestedFileName);
|
||||
Task<string?> OpenBackupAsync();
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IFileSaveService
|
||||
{
|
||||
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
|
||||
Task OpenFileAsync(string filePath);
|
||||
Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
|
||||
Task<FileOperationResult> OpenFileAsync(string filePath);
|
||||
Task<FileOperationResult> OpenFolderAsync(string filePath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Duempelkas.App.Services.Models;
|
||||
|
||||
public sealed record AccountSetup(string Name, decimal InitialBalance);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Duempelkas.App.Services.Models;
|
||||
|
||||
public sealed record BookingSaved(int EntryId, DateTime Date, string Kind, decimal Amount, bool Edited);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Duempelkas.App.Services.Models;
|
||||
|
||||
public enum FileOperationStatus { Success, Cancelled, Error }
|
||||
|
||||
public sealed record FileOperationResult(FileOperationStatus Status, string Message, string? Path = null, string? Details = null)
|
||||
{
|
||||
public static FileOperationResult Cancelled() => new(FileOperationStatus.Cancelled, "Vorgang abgebrochen. Es wurde nichts geändert.");
|
||||
public static FileOperationResult Error(Exception error) => new(FileOperationStatus.Error,
|
||||
"Die Dateiaktion konnte nicht abgeschlossen werden. Bitte den Speicherort und die Zugriffsrechte prüfen oder einen anderen Speicherort wählen.", Details: error.Message);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
/// <summary>The user's explicit selection survives navigation, including All years.</summary>
|
||||
public sealed class ReportingPeriod
|
||||
{
|
||||
public int? Year { get; set; } = DateTime.Today.Year;
|
||||
public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
|
||||
public List<int> Choices(IEnumerable<int> entryYears) => entryYears
|
||||
.Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty<int>())
|
||||
.Distinct().OrderDescending().ToList();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public enum ToastKind { Info, Success, Error }
|
||||
public sealed record ToastMessage(Guid Id, string Message, ToastKind Kind, string? Details,
|
||||
string? ActionLabel, Func<Task>? Action);
|
||||
|
||||
public sealed class ToastService : IDisposable
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly Dictionary<Guid, (ToastMessage Message, Timer? Timer, TimeSpan? Remaining, DateTimeOffset Started)> items = [];
|
||||
public event Action? Changed;
|
||||
public IReadOnlyList<ToastMessage> Messages { get { lock (sync) return items.Values.Select(x => x.Message).ToArray(); } }
|
||||
|
||||
public Guid Show(string message, ToastKind kind = ToastKind.Info, string? details = null,
|
||||
string? actionLabel = null, Func<Task>? action = null, TimeSpan? duration = null, bool persistent = false)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var lifetime = persistent ? (TimeSpan?)null : duration ?? TimeSpan.FromSeconds(kind == ToastKind.Error ? 15 : 9);
|
||||
var toast = new ToastMessage(id, message, kind, details, actionLabel, action);
|
||||
lock (sync)
|
||||
{
|
||||
Timer? timer = lifetime.HasValue ? new Timer(_ => Dismiss(id), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) : null;
|
||||
items[id] = (toast, timer, lifetime, DateTimeOffset.UtcNow);
|
||||
if (lifetime.HasValue) timer!.Change(lifetime.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
Changed?.Invoke();
|
||||
return id;
|
||||
}
|
||||
|
||||
public void Pause(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started == default) return;
|
||||
item.Timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
var left = item.Remaining.Value - (DateTimeOffset.UtcNow - item.Started);
|
||||
items[id] = (item.Message, item.Timer, left > TimeSpan.Zero ? left : TimeSpan.FromMilliseconds(1), default);
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started != default) return;
|
||||
items[id] = (item.Message, item.Timer, item.Remaining, DateTimeOffset.UtcNow);
|
||||
item.Timer?.Change(item.Remaining.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dismiss(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.Remove(id, out var item)) return;
|
||||
item.Timer?.Dispose();
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (sync) { foreach (var item in items.Values) item.Timer?.Dispose(); items.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ class Program
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas");
|
||||
var isolated = false;
|
||||
#if DEBUG
|
||||
var directoryIndex = Array.IndexOf(args, "--data-directory");
|
||||
if (directoryIndex >= 0)
|
||||
{
|
||||
if (directoryIndex + 1 >= args.Length) throw new ArgumentException("Datenordner fehlt.");
|
||||
appDataDir = Path.GetFullPath(args[directoryIndex + 1]);
|
||||
isolated = true;
|
||||
}
|
||||
#endif
|
||||
Directory.CreateDirectory(appDataDir);
|
||||
|
||||
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||
@@ -34,19 +44,23 @@ class Program
|
||||
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
||||
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
if (!isolated && !File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Copy(legacyDbPath, dbPath);
|
||||
using var source = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={legacyDbPath};Mode=ReadOnly");
|
||||
using var target = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath}");
|
||||
source.Open();
|
||||
target.Open();
|
||||
source.BackupDatabase(target);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore copy errors and continue with a fresh DB.
|
||||
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht übernommen werden. Der Start wurde abgebrochen; die ursprüngliche Datei bleibt erhalten.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
||||
appBuilder.Services.AddInfrastructure(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = dbPath }.ToString(), appDataDir);
|
||||
|
||||
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||
|
||||
@@ -68,9 +82,7 @@ class Program
|
||||
}
|
||||
|
||||
// Load saved window size or use defaults
|
||||
var settingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas", "window.json");
|
||||
var settingsPath = Path.Combine(appDataDir, "window.json");
|
||||
|
||||
int savedWidth = 1280, savedHeight = 800;
|
||||
if (File.Exists(settingsPath))
|
||||
@@ -86,7 +98,7 @@ class Program
|
||||
}
|
||||
|
||||
app.MainWindow
|
||||
.SetTitle("Dümpelkas – Kassenbuch")
|
||||
.SetTitle(isolated ? "Dümpelkas – Testdaten" : "Dümpelkas – Kassenbuch")
|
||||
.SetSize(savedWidth, savedHeight)
|
||||
.SetUseOsDefaultSize(false);
|
||||
|
||||
|
||||
@@ -217,8 +217,14 @@ html, body {
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
width: auto;
|
||||
min-width: 7rem;
|
||||
max-width: 15rem;
|
||||
min-height: 75px;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -588,3 +594,67 @@ input[type="number"].no-spinner {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.more-actions { align-self: center; }
|
||||
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
|
||||
|
||||
.form-container > div { min-width: 0; }
|
||||
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
|
||||
|
||||
.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
|
||||
|
||||
fieldset { min-width: 0; }
|
||||
.entry-recent > td { animation: saved-booking 6s ease-out; }
|
||||
@keyframes saved-booking {
|
||||
from { background-color: var(--color-surface-hover); box-shadow: inset 0 2px var(--color-accent), inset 0 -2px var(--color-accent); }
|
||||
to { background-color: transparent; box-shadow: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); }
|
||||
}
|
||||
|
||||
/* One icon row plus two reserved text rows on every page. */
|
||||
.action-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; padding: 1rem 0; align-items: flex-start; }
|
||||
.action-toolbar .btn-nav { box-sizing: border-box; width: 10.5rem; min-width: 10.5rem; max-width: 10.5rem; height: 6.25rem; min-height: 6.25rem; padding: .45rem .6rem; font-size: .9rem !important; line-height: 1.15rem; gap: .2rem; border-radius: .5rem; border: 1px solid transparent; overflow-wrap: normal; }
|
||||
.action-toolbar .btn-nav i { display: block; font-size: 1.65rem; line-height: 1.85rem; }
|
||||
.action-toolbar .btn-nav span { display: block; min-height: 2.3rem; }
|
||||
.action-toolbar .more-actions { align-self: flex-start; position: relative; }
|
||||
.action-toolbar .more-actions[open] { padding: 0; border: 0; }
|
||||
.more-actions > button { position: absolute; z-index: 20; right: 0; min-width: 15rem; box-shadow: 0 4px 16px #0004; }
|
||||
.toast-stack { position: fixed; z-index: 3000; inset: .75rem .75rem auto auto; width: min(28rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 1.5rem); overflow-y: auto; display: flex; flex-direction: column; gap: .65rem; pointer-events: none; }
|
||||
.app-toast { pointer-events: auto; padding: 1rem 2.5rem 1rem 1rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-left: .35rem solid var(--color-accent); border-radius: .65rem; box-shadow: 0 5px 22px #0004; position: relative; animation: toast-enter .2s ease-out; overflow-wrap: anywhere; }
|
||||
.toast-success { border-left-color: var(--color-income); }
|
||||
.toast-error { border-left-color: var(--color-expense); }
|
||||
.app-toast p { margin: 0 0 .5rem; }
|
||||
.toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.5rem; }
|
||||
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
|
||||
.input-rejection:empty { display: none; }
|
||||
|
||||
.app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; }
|
||||
.app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); }
|
||||
.dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.account-card { display: block; width: 100%; color: inherit; text-decoration: none; }
|
||||
:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
|
||||
fieldset { border: 0; margin: 0; padding: 0; }
|
||||
|
||||
body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
|
||||
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
|
||||
.entry-actions button, .entry-action-menu summary { min-height: 44px; padding: .6rem; }
|
||||
.entry-action-menu summary { cursor: pointer; border: 1px solid var(--color-border); border-radius: .4rem; }
|
||||
.entry-action-menu button { display: block; width: 100%; margin-top: .5rem; }
|
||||
.entry-description { overflow-wrap: anywhere; }
|
||||
@media (max-width: 900px) {
|
||||
.entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
.entry-list table, .entry-list tbody { display: block; width: 100%; }
|
||||
.entry-list tbody tr { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--color-border); border-radius: .5rem; padding: .6rem; margin-bottom: .75rem; }
|
||||
.entry-list td { display: block; border: 0; min-width: 0; padding: .3rem; text-align: left !important; }
|
||||
.entry-list td::before { content: attr(data-label); display: block; color: var(--color-text-muted); font-size: .85rem; }
|
||||
.entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.history-toggle { display: block; padding: .75rem 0; }
|
||||
.entry-deleted { opacity: 1 !important; }
|
||||
.deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; }
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
<a class="dismiss">×</a>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script src="_framework/blazor.webview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Keep decimal editing predictable: reject the complete invalid edit/paste, never round it.
|
||||
(() => {
|
||||
function allowed(input, value) {
|
||||
return (input.dataset.allowNegative === 'true' ? /^-?\d*([,.]\d{0,2})?$/ : /^\d*([,.]\d{0,2})?$/).test(value);
|
||||
}
|
||||
function accept(input, text) {
|
||||
const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd);
|
||||
const ok = allowed(input, next);
|
||||
const hint = document.getElementById(input.id + '-rejected');
|
||||
if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.';
|
||||
return ok;
|
||||
}
|
||||
document.addEventListener('beforeinput', e => {
|
||||
if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('paste', e => {
|
||||
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
|
||||
});
|
||||
})();
|
||||
|
||||
window.duempelkasDialog = {
|
||||
open(dialog, receiver) {
|
||||
const opener = document.activeElement;
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); receiver.invokeMethodAsync('RequestCloseAsync'); });
|
||||
dialog.addEventListener('close', () => { if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
const initial = dialog.querySelector('[data-safe-focus]') || dialog.querySelector('[data-initial-focus]') || dialog.querySelector('input,select,textarea,button');
|
||||
initial?.focus();
|
||||
},
|
||||
close(dialog) { if (dialog?.open) dialog.close(); },
|
||||
focus(dialog, selector) { dialog.querySelector(selector)?.focus(); }
|
||||
};
|
||||
@@ -8,10 +8,11 @@ namespace Duempelkas.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString, string? settingsDirectory = null)
|
||||
{
|
||||
services.AddDbContext<FinanceDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
services.AddSingleton(new DatabaseLocation(connectionString));
|
||||
// The factory also registers FinanceDbContext as scoped. Registering
|
||||
// AddDbContext first would give its singleton factory scoped options.
|
||||
services.AddDbContextFactory<FinanceDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
@@ -19,9 +20,12 @@ public static class DependencyInjection
|
||||
services.AddScoped<IEntryService, EntryService>();
|
||||
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
||||
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
||||
services.AddSingleton<IFileDialogService, FileDialogService>();
|
||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||
services.AddSingleton<IBackupService, BackupService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<ISettingsService>(new SettingsService(settingsDirectory));
|
||||
services.AddSingleton<ReportingPeriod>();
|
||||
services.AddSingleton<ToastService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
|
||||
{
|
||||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
||||
|
||||
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
private readonly DatabaseLocation? _location;
|
||||
|
||||
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_location = location;
|
||||
}
|
||||
|
||||
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||
{
|
||||
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
|
||||
|
||||
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var accounts = await db.Accounts
|
||||
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
|
||||
|
||||
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts
|
||||
@@ -85,18 +93,24 @@ public class AccountService : IAccountService
|
||||
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
|
||||
public Task<AccountSummaryDto> CreateAccountAsync(string name) => CreateAccountAsync(name, 0m);
|
||||
|
||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Bitte einen Kontonamen eingeben.");
|
||||
if (decimal.Round(initialBalance, 2) != initialBalance) throw new ArgumentException("Ungültiger Anfangsbestand.");
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = new Account { Name = name };
|
||||
var account = new Account { Name = name.Trim(), CarryoverBalance = initialBalance };
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc);
|
||||
return new AccountSummaryDto(account.Id, account.Name, initialBalance, initialBalance, account.CreatedUtc);
|
||||
}
|
||||
|
||||
public async Task RenameAccountAsync(int accountId, string newName)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts.FindAsync(accountId)
|
||||
@@ -107,6 +121,7 @@ public class AccountService : IAccountService
|
||||
|
||||
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts.FindAsync(accountId)
|
||||
@@ -123,6 +138,7 @@ public class AccountService : IAccountService
|
||||
return;
|
||||
}
|
||||
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts.FindAsync(accountId)
|
||||
@@ -136,8 +152,25 @@ public class AccountService : IAccountService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial)
|
||||
{
|
||||
if (decimal.Round(value, 2) != value) throw new ArgumentException("Ungültiger Bestand.");
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var account = await db.Accounts.FindAsync(accountId) ?? throw new InvalidOperationException("Konto nicht mehr vorhanden.");
|
||||
var movement = year.HasValue ? await db.Entries.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value)
|
||||
.SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount) : 0m;
|
||||
if (account.CarryoverBalance != expectedInitial || account.CarryoverBalance + movement != expectedCarryover)
|
||||
throw new InvalidOperationException("Der Bestand wurde inzwischen geändert. Bitte abbrechen und die Korrektur mit aktuellen Werten erneut öffnen.");
|
||||
account.CarryoverBalance = value - movement;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(int accountId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Duempelkas.App.Services;
|
||||
@@ -7,140 +8,126 @@ namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private static readonly string DbPath = Path.Combine(
|
||||
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
|
||||
"duempelkas.db");
|
||||
private readonly DatabaseLocation location;
|
||||
|
||||
public async Task<string> CreateBackupAsync()
|
||||
private readonly IFileDialogService dialogs;
|
||||
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
|
||||
{
|
||||
if (!File.Exists(DbPath))
|
||||
{
|
||||
return "Keine Datenbankdatei gefunden.";
|
||||
}
|
||||
|
||||
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
|
||||
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(backupPath))
|
||||
{
|
||||
return "Backup wurde abgebrochen.";
|
||||
}
|
||||
|
||||
backupPath = Path.ChangeExtension(backupPath, ".bak");
|
||||
|
||||
try
|
||||
{
|
||||
File.Copy(DbPath, backupPath, overwrite: true);
|
||||
return $"Backup gespeichert: {backupPath}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Backup fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
this.location = location;
|
||||
this.dialogs = dialogs ?? new FileDialogService();
|
||||
}
|
||||
|
||||
public async Task<string> RestoreBackupAsync()
|
||||
public async Task<FileOperationResult> CreateBackupAsync()
|
||||
{
|
||||
var backupPath = await ShowOpenDialogAsync();
|
||||
if (string.IsNullOrWhiteSpace(backupPath))
|
||||
{
|
||||
return "Wiederherstellung wurde abgebrochen.";
|
||||
}
|
||||
|
||||
if (!File.Exists(backupPath))
|
||||
{
|
||||
return "Die gewählte Backup-Datei wurde nicht gefunden.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure no pooled connection keeps stale handles to the previous DB file.
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
var targetDirectory = Path.GetDirectoryName(DbPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
}
|
||||
|
||||
File.Copy(backupPath, DbPath, overwrite: true);
|
||||
SqliteConnection.ClearAllPools();
|
||||
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
|
||||
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
|
||||
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||
await CreateBackupToAsync(path);
|
||||
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
|
||||
}
|
||||
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||
}
|
||||
|
||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
||||
public async Task<FileOperationResult> RestoreBackupAsync()
|
||||
{
|
||||
return Task.Run(() =>
|
||||
try
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
var path = await dialogs.OpenBackupAsync();
|
||||
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||
var fallback = await RestoreFromAsync(path);
|
||||
return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
|
||||
}
|
||||
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||
}
|
||||
|
||||
public async Task CreateBackupToAsync(string path)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(location);
|
||||
RejectActivePath(path);
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
return Path.Combine(documentsPath, suggestedFileName);
|
||||
Snapshot(location.Path, staged);
|
||||
File.Move(staged, path, overwrite: true);
|
||||
}
|
||||
|
||||
var script = $@"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
||||
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
|
||||
$dialog.DefaultExt = 'bak'
|
||||
$dialog.AddExtension = $true
|
||||
$dialog.OverwritePrompt = $true
|
||||
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
|
||||
$dialog.Title = 'Backup speichern'
|
||||
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
|
||||
";
|
||||
|
||||
return RunPowerShellDialog(script);
|
||||
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<string?> ShowOpenDialogAsync()
|
||||
public async Task<string> RestoreFromAsync(string path)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
using var lease = await DatabaseLease.AcquireAsync(location);
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
RejectActivePath(path);
|
||||
// Stage the selected database first, so a changing/removable source cannot
|
||||
// change between validation and restore. BackupDatabase includes WAL data.
|
||||
var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
return null;
|
||||
Snapshot(path, staged);
|
||||
Validate(staged);
|
||||
var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak";
|
||||
Snapshot(location.Path, fallback);
|
||||
// SQLite's online backup atomically replaces database contents, retaining
|
||||
// the active file/handles and coordinating locks. Never copy over an open DB.
|
||||
Snapshot(staged, location.Path);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var script = @"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.OpenFileDialog
|
||||
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
|
||||
$dialog.Multiselect = $false
|
||||
$dialog.Title = 'Backup auswählen'
|
||||
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
|
||||
";
|
||||
|
||||
return RunPowerShellDialog(script);
|
||||
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||
});
|
||||
}
|
||||
|
||||
private static string? RunPowerShellDialog(string script)
|
||||
private void RejectActivePath(string path)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell",
|
||||
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
|
||||
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
|
||||
}
|
||||
}
|
||||
|
||||
private static SqliteConnection Open(string path, SqliteOpenMode mode)
|
||||
{
|
||||
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false
|
||||
}.ToString());
|
||||
try { connection.Open(); return connection; }
|
||||
catch { connection.Dispose(); throw; }
|
||||
}
|
||||
|
||||
private static void Snapshot(string source, string destination)
|
||||
{
|
||||
if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein.");
|
||||
using var input = Open(source, SqliteOpenMode.ReadOnly);
|
||||
using var output = Open(destination, SqliteOpenMode.ReadWriteCreate);
|
||||
input.BackupDatabase(output);
|
||||
}
|
||||
|
||||
private static void Validate(string path)
|
||||
{
|
||||
using var connection = Open(path, SqliteOpenMode.ReadOnly);
|
||||
using var check = connection.CreateCommand();
|
||||
check.CommandText = "PRAGMA integrity_check";
|
||||
if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("Die Sicherung ist beschädigt.");
|
||||
check.CommandText = "PRAGMA foreign_key_check";
|
||||
using (var reader = check.ExecuteReader())
|
||||
if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen.");
|
||||
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
|
||||
Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection);
|
||||
using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options);
|
||||
var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray();
|
||||
var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray();
|
||||
if (!expected.SequenceEqual(applied))
|
||||
throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion.");
|
||||
// Materialize every mapped column to reject a forged/missing schema before touching active data.
|
||||
_ = db.Accounts.ToArray();
|
||||
_ = db.Entries.ToArray();
|
||||
_ = db.TransferLinks.ToArray();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable
|
||||
{
|
||||
public static async Task<DatabaseLease> AcquireAsync(DatabaseLocation? location)
|
||||
{
|
||||
if (location != null) await location.Writes.WaitAsync();
|
||||
return new DatabaseLease(location?.Writes);
|
||||
}
|
||||
|
||||
public void Dispose() => gate?.Release();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public sealed class DatabaseLocation(string connectionString)
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.GetFullPath(new SqliteConnectionStringBuilder(connectionString).DataSource);
|
||||
public string ConnectionString { get; } = connectionString;
|
||||
// All application mutations and restore share this gate. SQLite's backup API
|
||||
// also coordinates readers and external connections through SQLite locks.
|
||||
public SemaphoreSlim Writes { get; } = new(1, 1);
|
||||
}
|
||||
@@ -11,7 +11,13 @@ public class EntryService : IEntryService
|
||||
{
|
||||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
||||
|
||||
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
private readonly DatabaseLocation? _location;
|
||||
|
||||
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_location = location;
|
||||
}
|
||||
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
|
||||
{
|
||||
@@ -21,6 +27,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var query = db.Entries.Where(e => e.AccountId == accountId);
|
||||
@@ -67,6 +74,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task<List<int>> GetEntryYearsAsync(int accountId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
return await db.Entries
|
||||
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task<List<int>> GetAllEntryYearsAsync()
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
return await db.Entries
|
||||
@@ -90,6 +99,9 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||
{
|
||||
if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart.");
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
@@ -100,7 +112,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = displayId,
|
||||
Type = type,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
db.Entries.Add(entry);
|
||||
@@ -109,11 +121,13 @@ public class EntryService : IEntryService
|
||||
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
|
||||
}
|
||||
|
||||
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
if (sourceAccountId == targetAccountId)
|
||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -125,7 +139,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Expense,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
|
||||
@@ -138,7 +152,7 @@ public class EntryService : IEntryService
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Income,
|
||||
Date = date,
|
||||
Title = title,
|
||||
Title = title.Trim(),
|
||||
Amount = amount
|
||||
};
|
||||
|
||||
@@ -159,10 +173,12 @@ public class EntryService : IEntryService
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return sourceEntry.Id;
|
||||
}
|
||||
|
||||
public async Task DeleteEntryAsync(int entryId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var entry = await db.Entries.FindAsync(entryId)
|
||||
@@ -185,6 +201,7 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task RestoreEntryAsync(int entryId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var entry = await db.Entries.FindAsync(entryId)
|
||||
@@ -207,6 +224,8 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var entry = await db.Entries.FindAsync(entryId)
|
||||
@@ -216,7 +235,7 @@ public class EntryService : IEntryService
|
||||
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
||||
|
||||
entry.Date = date;
|
||||
entry.Title = title;
|
||||
entry.Title = title.Trim();
|
||||
entry.Amount = amount;
|
||||
|
||||
if (link != null)
|
||||
@@ -226,7 +245,7 @@ public class EntryService : IEntryService
|
||||
if (otherEntry != null)
|
||||
{
|
||||
otherEntry.Date = date;
|
||||
otherEntry.Title = title;
|
||||
otherEntry.Title = title.Trim();
|
||||
otherEntry.Amount = amount;
|
||||
}
|
||||
}
|
||||
@@ -236,6 +255,8 @@ public class EntryService : IEntryService
|
||||
|
||||
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
|
||||
{
|
||||
BookingInput.Validate(date, title, amount);
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var entry = await db.Entries.FindAsync(entryId)
|
||||
@@ -249,12 +270,17 @@ public class EntryService : IEntryService
|
||||
var otherEntry = await db.Entries.FindAsync(otherEntryId)
|
||||
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
|
||||
|
||||
if (newLinkedAccountId == entry.AccountId)
|
||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||
if (!await db.Accounts.AnyAsync(a => a.Id == newLinkedAccountId))
|
||||
throw new InvalidOperationException("Das gewählte Konto ist nicht mehr vorhanden.");
|
||||
|
||||
// Update date, title, amount on both sides
|
||||
entry.Date = date;
|
||||
entry.Title = title;
|
||||
entry.Title = title.Trim();
|
||||
entry.Amount = amount;
|
||||
otherEntry.Date = date;
|
||||
otherEntry.Title = title;
|
||||
otherEntry.Title = title.Trim();
|
||||
otherEntry.Amount = amount;
|
||||
|
||||
// If the linked account has changed, move the other entry to the new account
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Duempelkas.App.Services;
|
||||
|
||||
namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public sealed class FileDialogService : IFileDialogService
|
||||
{
|
||||
public Task<string?> SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName);
|
||||
public Task<string?> OpenBackupAsync() => ShowAsync(null);
|
||||
|
||||
private static Task<string?> ShowAsync(string? suggestedFileName) => Task.Run(() =>
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
throw new PlatformNotSupportedException("Der Dateidialog ist auf diesem System nicht verfügbar.");
|
||||
var save = suggestedFileName != null;
|
||||
var extension = save ? (Path.GetExtension(suggestedFileName) ?? "") : ".bak";
|
||||
var script = $$"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.{{(save ? "SaveFileDialog" : "OpenFileDialog")}}
|
||||
$dialog.Filter = 'Dateien (*{{extension.Replace("'", "''")}})|*{{extension.Replace("'", "''")}}'
|
||||
$dialog.Title = '{{(save ? "Datei speichern" : "Sicherung auswählen")}}'
|
||||
{{(save ? "$dialog.OverwritePrompt = $true\n$dialog.AddExtension = $true\n$dialog.DefaultExt = '" + extension.TrimStart('.').Replace("'", "''") + "'\n$dialog.FileName = '" + suggestedFileName!.Replace("'", "''") + "'" : "$dialog.Multiselect = $false")}}
|
||||
try { if ($dialog.ShowDialog() -eq 'OK') { [Console]::Write($dialog.FileName) } } finally { $dialog.Dispose() }
|
||||
""";
|
||||
var start = new ProcessStartInfo("powershell.exe")
|
||||
{
|
||||
RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-STA");
|
||||
start.ArgumentList.Add("-EncodedCommand");
|
||||
start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
|
||||
using var process = Process.Start(start) ?? throw new IOException("Der Dateidialog konnte nicht gestartet werden.");
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) throw new IOException("Der Dateidialog konnte nicht geöffnet werden. " + error);
|
||||
return string.IsNullOrWhiteSpace(output) ? null : output;
|
||||
});
|
||||
}
|
||||
@@ -1,90 +1,49 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Duempelkas.App.Services;
|
||||
|
||||
namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public class FileSaveService : IFileSaveService
|
||||
public class FileSaveService(IFileDialogService dialogs) : IFileSaveService
|
||||
{
|
||||
public async Task<string?> SaveFileAsync(byte[] content, string suggestedFileName)
|
||||
public async Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName)
|
||||
{
|
||||
var filePath = await ShowSaveDialogAsync(suggestedFileName);
|
||||
if (filePath == null) return null;
|
||||
|
||||
await File.WriteAllBytesAsync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Task OpenFileAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
string? staged = null;
|
||||
try
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
var path = await dialogs.SaveAsync(suggestedFileName);
|
||||
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||
staged = path + $".{Guid.NewGuid():N}.tmp";
|
||||
await File.WriteAllBytesAsync(staged, content);
|
||||
File.Move(staged, path, overwrite: true);
|
||||
return new(FileOperationStatus.Success, "Datei gespeichert.", path);
|
||||
}
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
Process.Start("xdg-open", filePath);
|
||||
});
|
||||
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
|
||||
}
|
||||
|
||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
||||
public Task<FileOperationResult> OpenFileAsync(string filePath) => OpenAsync(filePath, false);
|
||||
public Task<FileOperationResult> OpenFolderAsync(string filePath) => OpenAsync(filePath, true);
|
||||
|
||||
private static Task<FileOperationResult> OpenAsync(string filePath, bool folder) => Task.Run(() =>
|
||||
{
|
||||
return Task.Run(() =>
|
||||
try
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Fallback: save to Documents folder
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
return Path.Combine(documentsPath, suggestedFileName);
|
||||
}
|
||||
var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
|
||||
if (folder ? !Directory.Exists(path) : !File.Exists(path))
|
||||
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
|
||||
var start = new ProcessStartInfo { UseShellExecute = true };
|
||||
if (OperatingSystem.IsWindows()) start.FileName = path;
|
||||
else { start.FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; start.ArgumentList.Add(path); }
|
||||
using var process = Process.Start(start) ?? throw new IOException("Kein Programm zum Öffnen gefunden.");
|
||||
return new FileOperationResult(FileOperationStatus.Success, folder ? "Ordner geöffnet." : "Datei geöffnet.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new FileOperationResult(FileOperationStatus.Error,
|
||||
"Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
var extension = Path.GetExtension(suggestedFileName);
|
||||
var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien";
|
||||
var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*";
|
||||
|
||||
var script = $@"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
||||
$dialog.Filter = '{filter}'
|
||||
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
|
||||
$dialog.Title = 'Datei speichern'
|
||||
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
|
||||
";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell",
|
||||
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return null;
|
||||
|
||||
var result = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService
|
||||
{
|
||||
col.Item().Text(clubName).Bold().FontSize(20);
|
||||
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1);
|
||||
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}" : "Zeitraum: Alle Jahre").FontSize(9);
|
||||
col.Item().Text("Nur wirksame Buchungen. Umbuchungen zählen im einzelnen Konto als Zu- oder Abgang.").FontSize(8);
|
||||
col.Item().PaddingTop(5).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
|
||||
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
||||
});
|
||||
|
||||
foreach (var entry in entries)
|
||||
foreach (var entry in entries.Where(e => !e.IsDeleted))
|
||||
{
|
||||
var titleText = entry.Title;
|
||||
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
||||
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
|
||||
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
SummaryRow(summaryCol, "Anfangsbestand:", balance.CarryoverBalance);
|
||||
}
|
||||
|
||||
SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
|
||||
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
||||
summaryCol.Item().PaddingTop(5).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
|
||||
row.RelativeItem().Text(year.HasValue ? $"Kontostand am 31.12.{year}:" : "Kontostand nach allen Buchungen:").Bold().FontSize(12);
|
||||
row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
|
||||
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
});
|
||||
@@ -302,6 +309,8 @@ public class PdfStatementService : IPdfStatementService
|
||||
? $"Übersicht aller Konten ({year.Value})"
|
||||
: "Übersicht aller Konten";
|
||||
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
|
||||
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}; Endstände am 31.12.{year}." : "Zeitraum: Alle Jahre; Endstände nach allen Buchungen.").FontSize(9);
|
||||
col.Item().Text("Nur wirksame Buchungen. Interne Umbuchungen erscheinen einmal mit Minus und Plus; die Vereinssumme bleibt unverändert.").FontSize(8);
|
||||
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
|
||||
.FontSize(10)
|
||||
.SemiBold()
|
||||
@@ -338,15 +347,14 @@ public class PdfStatementService : IPdfStatementService
|
||||
}
|
||||
});
|
||||
|
||||
if (year.HasValue && carryoverByAccountId is not null)
|
||||
{
|
||||
BodyCell(table, string.Empty);
|
||||
BodyCell(table, string.Empty);
|
||||
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
|
||||
BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
|
||||
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public class SettingsService : ISettingsService
|
||||
{
|
||||
private static readonly string SettingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas", "settings.json");
|
||||
private readonly string SettingsPath;
|
||||
|
||||
public SettingsService(string? directory = null) => SettingsPath = Path.Combine(directory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Duempelkas"), "settings.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
@@ -24,7 +25,7 @@ public class SettingsService : ISettingsService
|
||||
await SaveAsync(settings);
|
||||
}
|
||||
|
||||
private static async Task<AppSettings> LoadAsync()
|
||||
private async Task<AppSettings> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(SettingsPath))
|
||||
return new AppSettings();
|
||||
@@ -40,7 +41,7 @@ public class SettingsService : ISettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SaveAsync(AppSettings settings)
|
||||
private async Task SaveAsync(AppSettings settings)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(SettingsPath)!;
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public sealed class BackupServiceTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-tests-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly ServiceProvider services;
|
||||
private readonly BackupService backup;
|
||||
private readonly EntryService entries;
|
||||
private readonly int accountId;
|
||||
|
||||
public BackupServiceTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
services = new ServiceCollection().AddInfrastructure($"Data Source={Path.Combine(folder, "active.db")};Pooling=False").BuildServiceProvider();
|
||||
using var db = services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.Migrate();
|
||||
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
|
||||
db.Accounts.Add(new() { Name = "Isolierte Testkasse" });
|
||||
db.SaveChanges();
|
||||
accountId = db.Accounts.Single().Id;
|
||||
backup = new BackupService(services.GetRequiredService<DatabaseLocation>());
|
||||
entries = (EntryService)services.GetRequiredService<Duempelkas.App.Services.IEntryService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WalSnapshot_RestoresExactState_AndKeepsFallback()
|
||||
{
|
||||
var first = await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Gesichert", 12.50m);
|
||||
var path = Path.Combine(folder, "snapshot.bak");
|
||||
await backup.CreateBackupToAsync(path);
|
||||
await entries.CreateEntryAsync(accountId, EntryType.Expense, new(2026, 1, 2), "Später", 4.20m);
|
||||
var fallback = await backup.RestoreFromAsync(path);
|
||||
var restored = await entries.GetEntriesAsync(accountId, (int?)null);
|
||||
Assert.Single(restored);
|
||||
Assert.Equal(first, restored[0]);
|
||||
using var old = new SqliteConnection($"Data Source={fallback};Mode=ReadOnly;Pooling=False");
|
||||
old.Open();
|
||||
using var command = old.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM Entries";
|
||||
Assert.Equal(2L, command.ExecuteScalar());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task InvalidBackup_DoesNotChangeActiveData(bool sqlite)
|
||||
{
|
||||
await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Bleibt", 10m);
|
||||
var path = Path.Combine(folder, "invalid.bak");
|
||||
if (sqlite)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE Unrelated (Id INTEGER)";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
else await File.WriteAllTextAsync(path, "not a database");
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(path));
|
||||
Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSource_AndActiveDestination_AreRejected()
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(Path.Combine(folder, "missing.bak")));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => backup.CreateBackupToAsync(Path.Combine(folder, "active.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
services.Dispose();
|
||||
Directory.Delete(folder, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingFormTests
|
||||
{
|
||||
[Fact]
|
||||
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
||||
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
|
||||
Assert.Contains("Bitte beschreiben", dialog.Markup);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-title").Input("Porto");
|
||||
dialog.Find("#entry-amount").Input("12,501");
|
||||
Assert.Equal("", dialog.Find("#entry-amount").GetAttribute("value"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("-12,50");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-date").Input("");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
|
||||
dialog.Find("#entry-date").Input("2026-01-02");
|
||||
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Contains("Ausgabe", dialog.Markup);
|
||||
Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
|
||||
dialog.Find("#entry-title").Input("Korrigierter Text");
|
||||
dialog.Render(p => p.Add(x => x.EditEntry, entry));
|
||||
Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingInputTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("12,50", true)]
|
||||
[InlineData("12.50", true)]
|
||||
[InlineData(" 12,50 ", true)]
|
||||
[InlineData("12,500", false)]
|
||||
[InlineData("-12,50", false)]
|
||||
[InlineData("1.234,56", false)]
|
||||
[InlineData("1,234.56", false)]
|
||||
[InlineData("12,50 €", false)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("9999999999999999999999999999999999999", false)]
|
||||
public void DecimalInput_HasExplicitRules(string text, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
|
||||
if (valid) Assert.Equal(12.50m, amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceValidation_RejectsInvalidData()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(default, "Test", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, " ", 1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
|
||||
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
|
||||
Assert.Equal(default, BookingInput.Date("2026-02-30"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class BookingSaveTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RepeatedSubmission_StoresOnce_ThenAllowsDeliberateNewBooking(bool transfer)
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var page = fixture.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
OpenAndFill(page, transfer);
|
||||
var delayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.Entries.BeforeWrite = () => delayed.Task;
|
||||
var pending = page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Contains("Wird gespeichert", page.Markup));
|
||||
Assert.True(page.Find("button[type=submit]").HasAttribute("disabled"));
|
||||
// Directly deliver repeated submit events, including ones a disabled UI would suppress.
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
delayed.SetResult();
|
||||
await pending;
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".toast-success").TextContent));
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Single(page.FindAll("tr.entry-recent"));
|
||||
if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null));
|
||||
|
||||
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
|
||||
OpenAndFill(page, transfer);
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Empty(page.FindAll("form")));
|
||||
Assert.Equal(2, (await fixture.Entries.GetEntriesAsync(1, (int?)null)).Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Failure_PreservesInputs_AndRetryWorks(bool transfer)
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var page = fixture.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
OpenAndFill(page, transfer);
|
||||
fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler"));
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains("Eingaben bleiben erhalten", page.Markup);
|
||||
Assert.Empty(page.FindAll(".toast-success"));
|
||||
Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value"));
|
||||
Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value"));
|
||||
Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Markup));
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationFailureAfterCommit_RetriesRefreshWithoutWritingAgain()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var notifications = 0;
|
||||
var dialog = fixture.Context.Render<AddEntryDialog>(p => p
|
||||
.Add(x => x.AccountId, 1)
|
||||
.Add(x => x.OnSave, (BookingSaved _) =>
|
||||
{
|
||||
if (++notifications == 1) throw new IOException("Refresh fehlgeschlagen");
|
||||
}));
|
||||
dialog.Find("#entry-title").Input("Testbuchung");
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains(fixture.Context.Services.GetRequiredService<ToastService>().Messages, t => t.Message.Contains("Die Buchung ist gespeichert"));
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
Assert.Equal(2, notifications);
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
}
|
||||
|
||||
private static void OpenAndFill(IRenderedComponent<Routes> page, bool transfer)
|
||||
{
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains(transfer ? "Geld zwischen Konten" : "Einnahme oder Ausgabe")).Click();
|
||||
if (transfer) page.Find("#transfer-account").Change("2");
|
||||
page.Find(transfer ? "#transfer-title" : "#entry-title").Input("Testbuchung");
|
||||
page.Find(transfer ? "#transfer-amount" : "#entry-amount").Input("12,50");
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public BunitContext Context { get; } = new();
|
||||
public ControlledEntries Entries { get; }
|
||||
private readonly SqliteConnection anchor;
|
||||
public Fixture()
|
||||
{
|
||||
var connection = $"Data Source=save-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
anchor = new SqliteConnection(connection);
|
||||
anchor.Open();
|
||||
Context.Services.AddInfrastructure(connection);
|
||||
Context.Services.AddSingleton<IEntryService>(sp => new ControlledEntries(new EntryService(sp.GetRequiredService<IDbContextFactory<FinanceDbContext>>())));
|
||||
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" });
|
||||
db.SaveChanges();
|
||||
Entries = (ControlledEntries)Context.Services.GetRequiredService<IEntryService>();
|
||||
}
|
||||
public void Dispose() { Context.Dispose(); anchor.Dispose(); }
|
||||
}
|
||||
|
||||
private sealed class ControlledEntries(IEntryService inner) : IEntryService
|
||||
{
|
||||
public int WriteCalls;
|
||||
public Func<Task> BeforeWrite = () => Task.CompletedTask;
|
||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||
{ WriteCalls++; await BeforeWrite(); return await inner.CreateEntryAsync(accountId, type, date, title, amount); }
|
||||
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||
{ WriteCalls++; await BeforeWrite(); return await inner.CreateTransferAsync(sourceAccountId, targetAccountId, date, title, amount); }
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year);
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly);
|
||||
public Task<List<int>> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId);
|
||||
public Task<List<int>> GetAllEntryYearsAsync() => inner.GetAllEntryYearsAsync();
|
||||
public Task DeleteEntryAsync(int entryId) => inner.DeleteEntryAsync(entryId);
|
||||
public Task RestoreEntryAsync(int entryId) => inner.RestoreEntryAsync(entryId);
|
||||
public Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) => inner.UpdateEntryAsync(entryId, date, title, amount);
|
||||
public Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) => inner.UpdateTransferAsync(entryId, newLinkedAccountId, date, title, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class CarryoverCorrectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Correction_ShiftsAllYearsByDelta_AndRejectsStaleState()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var accounts = f.Service<IAccountService>();
|
||||
var entries = f.Service<IEntryService>();
|
||||
var balances = f.Service<IBalanceQueryService>();
|
||||
await entries.CreateEntryAsync(1, EntryType.Income, new(2025, 1, 1), "Alt", 10m);
|
||||
await entries.CreateEntryAsync(1, EntryType.Expense, new(2026, 1, 1), "Neu", 5m);
|
||||
await accounts.CorrectCarryoverAsync(1, 120m, 2026, 110m, 100m);
|
||||
Assert.Equal(110m, (await accounts.GetAccountAsync(1)).CarryoverBalance);
|
||||
Assert.Equal(120m, (await balances.GetAccountBalanceAsync(1, 2025)).TotalBalance);
|
||||
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2026)).TotalBalance);
|
||||
Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2027)).TotalBalance);
|
||||
Assert.Equal(2, (await entries.GetEntriesAsync(1, (int?)null)).Count);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => accounts.CorrectCarryoverAsync(1, 130m, 2026, 110m, 100m));
|
||||
var newAccount = await accounts.CreateAccountAsync("Neues Konto", -20m);
|
||||
Assert.Equal(-20m, newAccount.TotalBalance);
|
||||
Assert.Empty(await entries.GetEntriesAsync(newAccount.Id, (int?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectionForm_ShowsBeforeAfter_AndRequiresAcknowledgement()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var saved = 0m;
|
||||
var dialog = f.Context.Render<EditCarryoverDialog>(p => p.Add(x => x.CurrentAmount, 110m)
|
||||
.Add(x => x.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse")
|
||||
.Add(x => x.OnSave, value => saved = value));
|
||||
dialog.Find("#carryover").Input("120,00");
|
||||
Assert.Contains("frühere und spätere Jahre", dialog.Markup);
|
||||
Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("input[type=checkbox]").Change(true);
|
||||
dialog.Find("button.btn-success").Click();
|
||||
Assert.Equal(120m, saved);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
||||
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exports_ExcludeDeleted_ExplainTransfers_AndMatchBalances()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse", CarryoverBalance = 100m };
|
||||
var target = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(source, target);
|
||||
await _db.SaveChangesAsync();
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>().UseSqlite(_connectionString).Options;
|
||||
var factory = new TestDbContextFactory(options);
|
||||
var entries = new EntryService(factory);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 1), "Mitgliedsbeitrag", 12.50m);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Expense, new(2026, 1, 2), "Porto", 4.20m);
|
||||
await entries.CreateTransferAsync(source.Id, target.Id, new(2026, 1, 3), "Zur Bank", 50m);
|
||||
var deleted = await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 4), "GELOESCHTE_TESTBUCHUNG", 999m);
|
||||
await entries.DeleteEntryAsync(deleted.Id);
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2027, 1, 1), "ZUKUNFT_TESTBUCHUNG", 1m);
|
||||
var balance = await new BalanceQueryService(factory).GetAccountBalanceAsync(source.Id, 2026);
|
||||
Assert.Equal(58.30m, balance.TotalBalance);
|
||||
var single = await _pdfService.GenerateStatementAsync(source.Id, 2026);
|
||||
var combined = await _pdfService.GenerateDashboardStatementAsync(2026);
|
||||
foreach (var pdf in new[] { single, combined })
|
||||
{
|
||||
var text = Extract(pdf);
|
||||
Assert.Contains("Mitgliedsbeitrag", text);
|
||||
Assert.Contains("Porto", text);
|
||||
Assert.Contains("Zur Bank", text);
|
||||
Assert.DoesNotContain("GELOESCHTE_TESTBUCHUNG", text);
|
||||
Assert.DoesNotContain("ZUKUNFT_TESTBUCHUNG", text);
|
||||
Assert.Contains("58,30", text);
|
||||
Assert.Contains("31.12.2026", text);
|
||||
}
|
||||
Assert.Contains("108,30", Extract(combined));
|
||||
Assert.Contains("an Girokonto", Extract(single));
|
||||
var all = Extract(await _pdfService.GenerateStatementAsync(source.Id, (int?)null));
|
||||
Assert.Contains("Alle Jahre", all);
|
||||
Assert.Contains("Anfangsbestand", all);
|
||||
Assert.Contains("59,30", all);
|
||||
var evidence = Environment.GetEnvironmentVariable("DUEMPELKAS_PDF_EVIDENCE");
|
||||
if (evidence != null)
|
||||
{
|
||||
Directory.CreateDirectory(evidence);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "single.pdf"), single);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "combined.pdf"), combined);
|
||||
// Additional pages exercise repeated headers and the final summary.
|
||||
for (var i = 0; i < 90; i++)
|
||||
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 2, 1), $"Lesbarkeitsprüfung Buchung {i + 1}", 1m);
|
||||
await File.WriteAllBytesAsync(Path.Combine(evidence, "multipage.pdf"), await _pdfService.GenerateStatementAsync(source.Id, 2026));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Extract(byte[] pdf)
|
||||
{
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||
return string.Join("\n", document.GetPages().Select(page => UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" Version="2.13.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.16" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryLifecycleUiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DeleteTransfer_ExplainsBothAccounts_HidesRows_AndUndoRestoresBoth()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m);
|
||||
var page = f.Context.Render<Routes>();
|
||||
page.WaitForElement(".account-card").Click();
|
||||
page.WaitForElement(".entry-action-menu");
|
||||
page.Find("button.btn-outline-danger").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup));
|
||||
Assert.Contains("Barkasse", page.Find("dialog").TextContent);
|
||||
Assert.Contains("Girokonto", page.Find("dialog").TextContent);
|
||||
page.Find("dialog button.btn-danger").Click();
|
||||
page.WaitForAssertion(() => Assert.Empty(page.FindAll("tbody tr")));
|
||||
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.True(e.IsDeleted));
|
||||
page.Find(".history-toggle input").Change(true);
|
||||
Assert.Contains("zählt nicht zum Kontostand", page.Markup);
|
||||
page.Find(".toast-success .btn").Click();
|
||||
page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr")));
|
||||
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Accounts;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryPresentationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Actions_HaveVisibleTextAndBookingSpecificNames()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null);
|
||||
var edited = 0;
|
||||
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id));
|
||||
Assert.Contains("2026-001 Porto", table.Find("summary").GetAttribute("aria-label"));
|
||||
var edit = table.FindAll("button").Single(b => b.TextContent.Contains("Bearbeiten"));
|
||||
Assert.Contains("Porto", edit.GetAttribute("aria-label"));
|
||||
edit.Click();
|
||||
Assert.Equal(1, edited);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class FileOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cancel_IsNeutral()
|
||||
{
|
||||
var service = new FileSaveService(new Dialog(null));
|
||||
Assert.Equal(FileOperationStatus.Cancelled, (await service.SaveFileAsync([1], "test.pdf")).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndFailure_AreDistinct_AndOpeningMissingFileIsError()
|
||||
{
|
||||
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-files-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(folder);
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(folder, "Prüfung.pdf");
|
||||
var service = new FileSaveService(new Dialog(path));
|
||||
Assert.Equal(FileOperationStatus.Success, (await service.SaveFileAsync([1, 2, 3], "test.pdf")).Status);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||
// A directory cannot be overwritten as a file; the existing file is preserved.
|
||||
Assert.Equal(FileOperationStatus.Error, (await new FileSaveService(new Dialog(folder)).SaveFileAsync([4], "test.pdf")).Status);
|
||||
Assert.Equal(FileOperationStatus.Error, (await service.OpenFileAsync(Path.Combine(folder, "missing.pdf"))).Status);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||
}
|
||||
finally { Directory.Delete(folder, true); }
|
||||
}
|
||||
|
||||
private sealed class Dialog(string? path) : IFileDialogService
|
||||
{
|
||||
public Task<string?> SaveAsync(string suggestedFileName) => Task.FromResult(path);
|
||||
public Task<string?> OpenBackupAsync() => Task.FromResult(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bunit;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
internal sealed class LedgerUiFixture : IDisposable
|
||||
{
|
||||
public BunitContext Context { get; } = new();
|
||||
private readonly SqliteConnection anchor;
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-ui-" + Guid.NewGuid().ToString("N"));
|
||||
public LedgerUiFixture(bool accounts = true)
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var connection = $"Data Source=ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
anchor = new SqliteConnection(connection);
|
||||
anchor.Open();
|
||||
Context.Services.AddInfrastructure(connection, folder);
|
||||
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
if (accounts)
|
||||
{
|
||||
db.Accounts.AddRange(new() { Name = "Barkasse", CarryoverBalance = 100m }, new() { Name = "Girokonto" });
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
public T Service<T>() where T : notnull => Context.Services.GetRequiredService<T>();
|
||||
public void Dispose() { Context.Dispose(); anchor.Dispose(); Directory.Delete(folder, true); }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ModalDialogTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EscapeRequest_ClosesCleanDialog_ButProtectsDirtyInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var cancelled = 0;
|
||||
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Title, "Testdialog").Add(x => x.OnCancel, () => cancelled++));
|
||||
Assert.NotNull(modal.Find("dialog").GetAttribute("aria-labelledby"));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.Equal(1, cancelled);
|
||||
modal.Render(p => p.Add(x => x.Dirty, true));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.Equal(1, cancelled);
|
||||
Assert.Contains("Ungespeicherte Eingaben", modal.Markup);
|
||||
modal.Find("[data-discard-continue]").Click();
|
||||
Assert.Empty(modal.FindAll("[role=alertdialog]"));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
modal.Find("button.btn-outline-danger").Click();
|
||||
Assert.Equal(2, cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BusyDialog_CannotClose()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var cancelled = false;
|
||||
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Busy, true).Add(x => x.OnCancel, () => cancelled = true));
|
||||
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||
Assert.False(cancelled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ReportingPeriodTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyYearList_PreservesSelectedYear_AndOffersCurrentYear()
|
||||
{
|
||||
var period = new ReportingPeriod { Year = 2025 };
|
||||
Assert.Contains(2025, period.Choices([]));
|
||||
Assert.Contains(DateTime.Today.Year, period.Choices([]));
|
||||
Assert.Equal("Jahr: 2025", period.Label);
|
||||
period.Year = null;
|
||||
Assert.Equal("Alle Jahre", period.Label);
|
||||
Assert.Null(period.Year);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ToastTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToastsStack_ExpireIndependently_AndPauseDuringInteraction()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var host = context.Render<ToastHost>();
|
||||
var service = context.Services.GetRequiredService<ToastService>();
|
||||
var first = service.Show("Erste Meldung", duration: TimeSpan.FromMilliseconds(100));
|
||||
service.Pause(first);
|
||||
service.Show("Zweite Meldung", ToastKind.Success, persistent: true);
|
||||
host.WaitForAssertion(() => Assert.Equal(2, host.FindAll(".app-toast").Count));
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(2, service.Messages.Count);
|
||||
service.Resume(first);
|
||||
host.WaitForAssertion(() => Assert.Single(host.FindAll(".app-toast")));
|
||||
Assert.Contains("Zweite Meldung", host.Markup);
|
||||
host.Find(".toast-close").Click();
|
||||
Assert.Empty(service.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmountControl_RejectsWholePasteWithoutRounding()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
string amount = "12,50";
|
||||
var control = context.Render<AmountInput>(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value));
|
||||
control.Find("input").Input("12,501");
|
||||
Assert.Equal("12,50", amount);
|
||||
Assert.Contains("nicht übernommen", control.Markup);
|
||||
control.Find("input").Input("12.25");
|
||||
Assert.Equal("12.25", amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class TransferDirectionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task BothSides_ShowSameDirection(int accountId)
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var entry = Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||
var dialog = f.Context.Render<AddTransferDialog>(p => p.Add(x => x.SourceAccountId, accountId).Add(x => x.EditEntry, entry));
|
||||
dialog.WaitForAssertion(() => Assert.Contains("Von: Barkasse → Nach: Girokonto", dialog.Find("#transfer-direction").TextContent));
|
||||
Assert.Contains("Gesamtsumme unverändert", dialog.Markup);
|
||||
Assert.Contains(accountId == 2 ? "Von Konto" : "Nach Konto", dialog.Find("label[for=transfer-account]").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncomingEdit_MovesSource_AndRejectsSameAccountWithoutChanges()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
var third = await f.Service<IAccountService>().CreateAccountAsync("Sparkonto");
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var incoming = Assert.Single(await entries.GetEntriesAsync(2, (int?)null));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => entries.UpdateTransferAsync(incoming.Id, 2, DateTime.Today, "Unzulässig", 99m));
|
||||
Assert.Equal(20m, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Amount);
|
||||
await entries.UpdateTransferAsync(incoming.Id, third.Id, DateTime.Today, "Andere Quelle", 25m);
|
||||
Assert.Empty(await entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Equal(EntryType.Expense, Assert.Single(await entries.GetEntriesAsync(third.Id, (int?)null)).Type);
|
||||
Assert.Equal(EntryType.Income, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class YearNavigationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Navigation_PreservesYear_AndOutsideBookingOffersReturn()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
var connectionString = $"Data Source=year-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
using var anchor = new SqliteConnection(connectionString);
|
||||
anchor.Open();
|
||||
context.Services.AddInfrastructure(connectionString);
|
||||
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
using (var db = context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
db.Accounts.Add(new() { Name = "Testkasse" });
|
||||
db.SaveChanges();
|
||||
await context.Services.GetRequiredService<IEntryService>().CreateEntryAsync(db.Accounts.Single().Id,
|
||||
EntryType.Income, new(2025, 1, 1), "Vorjahr", 10m);
|
||||
}
|
||||
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
|
||||
var page = context.Render<Routes>();
|
||||
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForElement(".summary-section");
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
|
||||
var dialog = page.FindComponent<AddEntryDialog>();
|
||||
dialog.Find("input[type=date]").Input("2026-01-02");
|
||||
dialog.Find("#entry-title").Input("Außerhalb");
|
||||
dialog.Find("#entry-amount").Input("12.50");
|
||||
Assert.Contains("außerhalb", dialog.Markup);
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("input[type=checkbox]").Change(true);
|
||||
dialog.Find("form").Submit();
|
||||
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
|
||||
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
|
||||
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Jahr: 2026")).Click();
|
||||
page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click();
|
||||
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user