Usability U01–U16: sichere Abläufe und Bedienkorrekturen (#1)
Dieser PR setzt die Usability-Maßnahmen U01–U16 und die Nacharbeit an Betragseingabe, Toasts und Aktionsleisten um. Analyse: `docs/usability-analyse.md`; Plan und fortlaufendes Prüfprotokoll: `docs/usability-umsetzung.md`.
- **U01–U03:** konsistente SQLite-Sicherung der aktiven Daten einschließlich WAL, geprüfte Wiederherstellung mit Rückfallsicherung; strukturierte Dateioperationen; PDF-Auszüge nur mit wirksamen Buchungen, erklärtem Zeitraum, Kontostand und internen Umbuchungen.
- **U04–U07:** gemeinsamer Jahreskontext einschließlich Alle Jahre; Hinweise und Rückweg für abweichendes Buchungsjahr; Alltagssprache; erkennbare Konto-/Buchungsart und Validierung; Schutz vor mehrfacher Speicherung einschließlich Retry nach bereits bestätigtem Schreiben.
- **Nacharbeit:** gemeinsames Betragscontrol weist zusätzliche Nachkommastellen und ungültige Einfügungen vollständig ab, ohne zu kürzen oder zu runden. Globale stapelbare Toasts erscheinen oben, laufen zeitgesteuert ab und pausieren bei Hover/Fokus. Laufende Vorgänge bleiben bis Abschluss sichtbar. Einheitliche Aktionsbuttons reservieren Symbol und zwei Textzeilen. Debug-Datenordner für isolierte Frontend-Tests.
- **U08:** native Modaldialoge mit Fokus, inaktivem Hintergrund, Escape, Rückkehr zum Auslöser und Verlustschutz; semantische Kontolinks; sichere Bestätigungen starten auf Abbrechen.
- **U09:** größere beschriftete Zeilenaktionen mit Buchungsbezug und Kartenansicht im schmalen Fenster.
- **U10:** gelöschte Buchungen standardmäßig ausgeblendet, gezählt erreichbar und ausdrücklich nicht mitgerechnet; Bestätigungen erklären beide Transferkonten, gezieltes Rückgängig mit Zustandsprüfung.
- **U11:** dieselbe Von→Nach-Richtung auf beiden Seiten, festes/änderbares Konto und Minus/Plus-Vorschau; Dienst verhindert auch beim Bearbeiten gleiche Konten.
- **U12:** Anfangsbestand bei Einrichtung getrennt von späterer Korrektur. Korrektur zeigt Jahr/Konto und vorher/nachher, warnt vor Auswirkungen auf frühere/spätere Jahre und benötigt Zustimmung. Transaktionale Prüfung weist veraltete Vorschau ab. Die gemeinsame Bestandssemantik bleibt erhalten; keine Migration oder neue Jahresspeicherung.
- **U13:** lesbare Grundschrift, lokal gespeicherte Textgröße bis 200 %, umbrechende Namen, responsive Formulare/Karten und im Fenster scrollbar bleibende Dialoge. Stichproben der Text-/Betragsrollen mindestens 5,91:1 (hell) bzw. 8,37:1 (dunkel); primäre Aktionsfarben mindestens 6,47:1.
- **U14:** sichtbare lokale Hilfe und überspringbare Einführung mit gespeichertem Fortschritt: Vereinsname, eigenes Konto/Anfangsbestand, tatsächliche erste Buchung und Sicherung. Keine Registrierung oder automatisch erzeugte Beispielbuchungen.
- **U15:** Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutschen Betrag; Sortierung nach Datum, vorzeichenrichtigem Betrag oder Beschreibung. Trefferzahl und wirksame Treffermengensumme sind vom unveränderten Kontostand getrennt. Reset erhält den gewählten Jahreskontext.
- **U16:** Bootstrap 5.3.3 und Bootstrap Icons 1.11.3 samt lokalen Schriftdateien und MIT-Lizenzen werden ausgeliefert; keine CDN-Ressourcen beim Start. Unerwartete Fehler bieten Wiederholung/Übersicht und kopierbare Referenz/Fehlertyp ohne Buchungsdaten. Nicht gefunden bietet Rückweg. Erwartbare Speicherfehler erhalten Eingaben.
Validierung: **60/60 Tests in Debug und 60/60 in Release**, einschließlich SQLite-WAL/Restore, PDF-Textextraktion, mehrjähriger Bestandskorrektur, Transferänderungen von beiden Seiten, Löschung/Undo beider Transferbuchungen, Formular-/Navigationsabläufen, Einführungsfortsetzung, Suche aus 400 Einträgen und anonymisierter Fehleranzeige. Nach jeder Maßnahme Desktop gebaut und Debug-Tests ausgeführt; final auch Release-Desktop-Build. Keine Buildwarnungen, `git diff --check` sauber. PDF-Einzelkonto, Übersicht und mehrseitiger Auszug visuell geprüft.
Computer Use mit künstlichem Bestand: zusätzliche Nachkommastelle abgewiesen, gültige 12,50-€-Einnahme gespeichert, Kontostand und Toast kontrolliert. U08-Fokus im Eingabefeld, ausschließlich modaler Accessibility-Baum und Escape-Schließen beobachtet. Nach erneuter Fensterzuordnung finale Anwendung mit lokalen Ressourcen gestartet; beschriftetes Zeilenmenü und korrekte Umbuchungsrichtung von der empfangenen Seite kontrolliert. Bestandskorrektur zeigt Konto/Jahr, Formel, Vorher/Nachher und Warnung für alle Jahre, ohne zu speichern. Dabei gefundene Textbindung im Betragsfeld korrigiert und als Regression geprüft; ebenso erklärter Leerzustand, sichtbares Undo-Refresh und zweites Escape im Verlustschutz.
Offline-Layoutprüfung: tatsächliche gerenderte Komponenten mit Produktions-CSS/Schriften in Headless Chromium, Browsernetz blockiert, **0 externe Requests**. **16 Kombinationen** bei 768 × 480 CSS-Pixeln, hell/dunkel und 100/125/150/200 % Textgröße bestehen; keine horizontale Seitenüberschreitung, Dialog im Fenster, Speichern/Abbrechen durch Scrollen erreichbar. Repräsentative PNGs visuell geprüft.
Grenzen: Browser-Textvergrößerung ist kein vollständiger Test realer Windows-DPI-Einstellungen. Keine vollständige Screenreader-/Zielgruppenabnahme und kein Nutzertest mit unerfahrenen Personen behauptet. Native Fensterzuordnung war zeitweise instabil; deshalb sind automatisierte Komponentenprüfungen und Browser-Layoutnachweise ausdrücklich von den beschriebenen nativen Beobachtungen getrennt. Keine Vereinsdaten geändert oder zurückgespielt, keine Bankvorgänge ausgeführt. Keine neue Jahres-Speichersemantik; Rückfallsicherungen werden nicht automatisch gelöscht. Vereinsname, Einführungsfortschritt und Darstellung gehören nicht zum Datenbank-Backup.
U08–U12 wurden einzeln committed und als Paket gepusht; U13–U16 ebenfalls in eigenen sachlichen Commits. Nacharbeit an Betragseingabe/Toasts/Aktionsleisten liegt in `86a73b8`. Plan und Prüfprotokoll: `docs/usability-umsetzung.md`. Kein Merge; zur manuellen Analyse durch den Auftraggeber.
Weitere Bedienkorrekturen nach manueller Analyse:
- Betragscontrol blockiert zusätzliche Nachkommastellen und ungültige Einfügungen jetzt ohne Warnmeldung.
- Jahreswechselhinweis als Warnbox mit Symbol, Datum und beiden Jahren. Er erscheint beim Verlassen des Datumsfelds; der native Kalender bleibt beim Blättern über den Jahreswechsel geöffnet. Submit ohne vorherigen Fokuswechsel prüft den aktuellen Datumsentwurf; Verlustschutz berücksichtigt ihn sofort. Gilt auch für Umbuchungen.
- Historie als beschrifteter Switch mit vertikalem Abstand. Direkt sichtbare Stift-/Papierkorbbuttons mit mindestens 44 × 44 Pixeln, Title und buchungsbezogenem Accessibility-Namen ersetzen das Zeilenmenü.
- Windows-Anwendungssymbol: ungenutzte native Photino-Benachrichtigungsregistrierung deaktiviert, damit sie die explizite Windows-App-ID nicht überschreibt und keine dotnet-Host-Verknüpfungen erzeugt. Bestehendes Icon bleibt in EXE und nativem Fenster eingebunden.
Zusätzliche Validierung: 60/60 Debug- und 60/60 Release-Tests; beide Desktop-Builds und eigenständige Windows-Einzeldatei ohne Warnungen/Fehler. Alle 16 Offline-Layoutkombinationen erneut bestanden. Computer Use bestätigt Switch, direkten Bearbeiten-Button, stille Abweisung der dritten Nachkommastelle und geöffneten nativen Kalender beim Blättern von Dezember 2026 nach Januar 2027; Warnbox erscheint nach Wechsel zur Beschreibung. Kein Speichern in diesem Prüflauf. Das aus der veröffentlichten EXE extrahierte Icon und das Titelleistensymbol wurden visuell geprüft; Taskleistendarstellung nicht separat erfasst. UI-Nacharbeit in aa39281; Windows-Zuordnung in eigenem Commit. Kein Merge.
Weitere UI-Korrekturen in 038fbc0: Stift-/Papierkorbsymbole horizontal und vertikal zentriert, Tabellenkopf mit Designfarben statt festem hellem Hintergrund. Alle sechs allgemeinen „Dateiaktion läuft“-Meldungen durch konkrete Angaben zu Sicherung, Wiederherstellung, PDF-Übersicht, Kontoauszug bzw. PDF-Öffnen ersetzt. 60/60 Tests in Debug und Release, beide Desktop-Builds ohne Warnungen/Fehler. Zusätzliche Browserprüfung bei 100/200 % in hell/dunkel: Symbolzentrierung innerhalb 0,6 Pixeln, mindestens 44 × 44 Pixel große Buttons, Headerkontrast 17,85:1 (hell) bzw. 12,89:1 (dunkel); alle 16 bisherigen Offline-Layoutkombinationen bestanden und dunkler Tabellenkopf visuell geprüft.
Textgröße um „Klein (75 %)“ ergänzt, einschließlich Speicherung und Wiederherstellung beim Start. Debug-Suite 60/60 bestanden, Desktop-Build ohne Warnungen/Fehler. JavaScript-Prüfung bestätigt 12-Pixel-Grundschrift bei 75 % und korrektes Speichern/erneutes Laden der Auswahl.
Anzeige gelöschter Buchungen dauerhaft und kontenübergreifend: gespeicherte benutzerweite Einstellung wird bei jedem Konto geladen und bleibt über Neustarts sowie „Filter zurücksetzen“ bestehen, bis der Switch ausgeschaltet wird. Neue Regressionen prüfen Kontowechsel, Reset, Ausschalten, Neustart und Erhalt anderer Einstellungen. 62/62 Tests jeweils in Debug und Release; Debug-Build ohne Warnungen/Fehler. Vorhandenen Publish-Task erfolgreich ausgeführt: Publish/Duempelkas-v26.306.exe. Die bereits vorhandene lokale Kontokarten-Anpassung des Auftraggebers ist in der lokalen Veröffentlichung enthalten und bleibt außerhalb dieses Commits.
Über-Dialog zeigt neben der Version das Builddatum im Format TT.MM.JJJJ. Zeitstempel wird beim Desktop-Build als Assembly-Metadatum eingebettet und funktioniert dadurch auch bei der Einzeldatei-Veröffentlichung. Eingebettetes Datum aus erzeugter Desktop-Assembly geprüft; 62/62 Tests jeweils in Debug und Release, Debug-Build ohne Warnungen/Fehler. Publish-Task erneut erfolgreich ausgeführt und Publish/Duempelkas-v26.306.exe aktualisiert.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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,145 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## U13 – Lesbarkeit und Fenstergröße
|
||||||
|
|
||||||
|
Grundschrift 16 px, beschriftete Textgrößenwahl bis 200 % (lokal gemerkt), keine kleinen Großbuchstaben in Formular/Tabellenkopf. Lange Namen umbrechen, Dialoge passen ins Fenster und scrollen; bei schmaler Ansicht einspaltige Formulare und Buchungskarten. Licht-/Dunkelrollen für Text und Beträge getrennt; Gelöscht bleibt zusätzlich als Text erkennbar. Keine Änderung von Windows-DPI-Einstellungen und keine daraus abgeleitete vollständige Barrierefreiheitszusage. Kontrastmessung und Layout-Stichproben werden im Abschluss ergänzt.
|
||||||
|
|
||||||
|
U13-Kontrastmessung: Text-, Hilfstext-, Einnahme-, Ausgabe- und Umbuchungsfarben gegen Hintergrund/Oberfläche jeweils mindestens 5.91:1 (hell) und 8.37:1 (dunkel); beschriftete primäre/Erfassen-/Umbuchungs-/Löschbuttons mindestens 6.47:1. Dies sind berechnete Rollen-Stichproben, keine vollständige Messung sämtlicher Zustände.
|
||||||
|
|
||||||
|
## U14 – Lokale Hilfe und Einführung
|
||||||
|
|
||||||
|
Hilfe steht in der Navigation und wird als Teil der Anwendung offline ausgeliefert. Sie beschreibt die aktuellen Abläufe einschließlich Anfangsbestand, Undo, Suche, Sicherungsumfang und Fehlerauswegen. Überspringbare Einführung mit Vereinsname, erstem Konto/erklärtem Anfangsbestand, echter erster Buchung und Sicherung; Fortschritt wird in den lokalen Einstellungen gespeichert, keine Registrierung und keine automatischen Beispielbuchungen. Der Integrationstest überspringt/öffnet erneut, legt ein selbst benanntes Konto mit Anfangsbestand an und bestätigt die leere Buchungsliste.
|
||||||
|
|
||||||
|
## U15 – Suchen und Sortieren
|
||||||
|
|
||||||
|
Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutsche Betragsdarstellung; mehrere Suchwörter müssen gemeinsam passen. Auswahl für Datum, vorzeichenrichtigen Betrag und Beschreibung. Trefferzahl und eigene wirksame Treffermengensumme erklären ausdrücklich den unveränderten Kontostand des gewählten Zeitraums. Zurücksetzen zeigt dessen vollständige aktive Liste; Historie bleibt gesondert. Test sucht mit Umlaut und 12,50 aus 400 Einträgen, prüft Nummer, Leerzustand, Reset und unveränderte Originalbeträge.
|
||||||
|
|
||||||
|
## U16 – Offline und Fehlerauswege
|
||||||
|
|
||||||
|
Bootstrap 5.3.3 sowie Bootstrap Icons 1.11.3 einschließlich Schriftdateien und MIT-Lizenzen werden lokal ausgeliefert; Startseite lädt keine CDN-Dateien mehr. Ressourcenprüfung prüft CSS-/JS-/Schriftdateien. Fehlergrenze bietet sicheren Wiederholungs-/Übersichtsweg, behauptet keine Speicherung und gibt nur Referenz/Fehlertyp statt potenzieller Buchungsdaten aus; Angaben sind kopierbar. Nicht gefunden bietet Rückweg. Erwartbare Speicherfehler sind weiterhin mit erhaltenen Eingaben als Toast behandelt. Paket- und Offline-Layoutprüfung folgt im Abschluss.
|
||||||
|
|
||||||
|
## Abschließende Prüfungen U08–U16
|
||||||
|
|
||||||
|
Debug-Suite nach jeder Maßnahme bestanden: U08 44, U09 45, U10 46, U11 49, U12 51, U13 51, U14 52, U15 54, U16 57 Tests. Desktop jeweils ohne Warnungen/Fehler gebaut; abschließende Release-Suite 57/57 bestanden.
|
||||||
|
|
||||||
|
Zusätzliche Headless-Chromium-Prüfung der tatsächlich gerenderten Konto-/Buchungs-Komponenten mit lokalen Produktionsressourcen: 16 Kombinationen bei 768 × 480 CSS-Pixeln, hell/dunkel und 100/125/150/200 % Textgröße. Browsernetz blockiert, keine externen Requests, lokale Symbole sichtbar. Kein horizontaler Seitenüberlauf; Dialog im Fenster, Speichern per Scrollen erreichbar. PNGs wurden exemplarisch kontrolliert. Dies prüft Textvergrößerung und Browserlayout, nicht sämtliche realen Windows-DPI-/Screenreader-Kombinationen.
|
||||||
|
|
||||||
|
Computer Use wurde nach der Unterbrechung fortgesetzt. U08: semantische Kontolinks, sichtbarer Eingabefokus, modaler Accessibility-Baum ohne Hintergrund und Escape-Schließen geprüft. Nach behobener Fensterzuordnung: finale Anwendung mit lokalen Bootstrap-Ressourcen gestartet; beschriftetes Zeilenmenü geöffnet und empfangene Umbuchung mit korrekter Von→Nach-Richtung, festem/änderbarem Konto sowie Minus/Plus-Vorschau gelesen. Sämtliche Tätigkeiten fanden im künstlichen Debug-Datenordner statt. Kein tatsächlicher Bankvorgang und keine Änderung am Vereinsbestand. Native vollständige DPI-/Hilfstechnologie-/Zielgruppenabnahme bleibt offen.
|
||||||
|
|
||||||
|
Abschlusskorrekturen aus der nativen Prüfung: Betragswert im Korrekturdialog und erklärter Leerzustand werden als Werte statt als Parametertexte gebunden; Regressionen prüfen beide Ausgangsanzeigen. Rückgängig aktualisiert auch den sichtbaren Löschstatus und verwendet nach Navigation weiterhin das ursprüngliche Konto. Eine zweite Escape-Anforderung im Verlustschutz rendert den Wechsel zurück zur Bearbeitung. Finale Prüfung: **58/58 Debug- und 58/58 Release-Tests**, Desktop Debug/Release ohne Warnungen/Fehler; alle 16 Offline-Layoutkombinationen erneut bestanden. Die native Prüfung der Bestandskorrektur bestätigte Konto/Jahr, Formel, Vorher/Nachher und Warnung für alle Jahre; es wurde keine Korrektur gespeichert. Der anschließend korrigierte Zahlenwert ist zusätzlich automatisiert geprüft.
|
||||||
|
|
||||||
|
## Weitere Bedienkorrekturen nach manueller Analyse
|
||||||
|
|
||||||
|
Zusätzliche Nachkommastellen und ungültige Einfügungen werden weiterhin vollständig abgewiesen, jetzt ohne Warnmeldung. Die Historie ist als beschrifteter Switch mit vertikalem Abstand erreichbar. Aktive Buchungen haben direkte Stift-/Papierkorbbuttons mit mindestens 44 × 44 Pixeln, Title und buchungsbezogener zugänglicher Beschriftung.
|
||||||
|
|
||||||
|
Der Jahreswechselhinweis ist eine Warnbox mit Symbol, Datum, angezeigtem und tatsächlichem Buchungsjahr. Die Prüfung erfolgt beim Verlassen des Datumsfelds, damit der native Kalender beim Blättern geöffnet bleibt. Vor dem Speichern wird auch ein noch nicht verlassenes Datumsfeld übernommen und validiert; der Verlustschutz berücksichtigt solche Eingaben bereits während der Bearbeitung. Das gilt für Einnahmen/Ausgaben und Umbuchungen.
|
||||||
|
|
||||||
|
Validierung: 60/60 Tests in Debug und Release; Desktop Debug/Release ohne Warnungen oder Fehler. Regressionen prüfen stille Betragsabweisung, direkte Aktionen, Datumskorrektur, verzögerte Prüfung, externe Datumswerte, Verlustschutz und Submit ohne vorherigen Fokuswechsel. Alle 16 Offline-Layoutkombinationen erneut bestanden. Native Computer-Use-Prüfung mit künstlichem Bestand: Switch blendet gelöschte Zeilen ein, direkter Stift öffnet Bearbeiten, dritte Nachkommastelle bleibt ohne Meldung blockiert. Im Erfassen-Dialog bleibt der Kalender beim Blättern von Dezember 2026 nach Januar 2027 geöffnet; nach Abschluss und Wechsel zur Beschreibung erscheint die Warnbox. Keine Testbuchung gespeichert und keine Vereinsdaten verändert.
|
||||||
|
|
||||||
|
## Windows-Anwendungssymbol
|
||||||
|
|
||||||
|
Das vorhandene Kassen-Icon bleibt in der Windows-EXE eingebettet und wird als natives Fenstersymbol gesetzt. Die ungenutzte Photino-Registrierung für Windows-Systembenachrichtigungen ist deaktiviert: Sie kann die explizite AppUserModelID überschreiben und Verknüpfungen zum generischen dotnet-Host anlegen. Damit bleibt die eigene Windows-Zuordnung für die Taskleiste erhalten; die in der Anwendung angezeigten Toasts funktionieren unabhängig davon. Bestehende Windows-Verknüpfungen werden nicht verändert.
|
||||||
|
|
||||||
|
Release als eigenständige Windows-Einzeldatei veröffentlicht. Das aus der veröffentlichten EXE extrahierte Anwendungssymbol entspricht dem vorhandenen Kassen-Icon; auch das native Testfenster zeigt es in der Titelleiste. Die Taskleistendarstellung wurde nicht separat visuell erfasst. Build und Veröffentlichung ohne Warnungen oder Fehler.
|
||||||
|
|
||||||
|
## Zentrierte Symbole, Tabellenkontrast und konkrete Laufmeldungen
|
||||||
|
|
||||||
|
Stift und Papierkorb sind in beiden Achsen zentriert; die Symbolschrift verwendet im Button eine Blockbox ohne Baseline-Versatz. Tabellenköpfe verwenden die Text- und Oberflächenfarbe des aktuellen Designs statt der festen Bootstrap-Klasse für helle Tabellen. Die sechs generischen Laufmeldungen benennen nun Sicherung erstellen, Sicherung wiederherstellen, Kontenübersicht als PDF erstellen/speichern, Kontoauszug als PDF erstellen/speichern oder gespeicherte PDF öffnen.
|
||||||
|
|
||||||
|
Debug-Tests: 60/60 bestanden. Zusätzliche Browserprüfung mit lokalem Produktions-CSS: Symbole bei 100 und 200 % in beiden Designs innerhalb von 0,6 Pixeln zentriert, Buttons mindestens 44 × 44 Pixel. Kontrast der sichtbaren Tabellenköpfe 17,85:1 (hell) und 12,89:1 (dunkel). Alle 16 bisherigen Offline-Layoutkombinationen bestehen weiterhin; dunkler Tabellenkopf und Symbole zusätzlich visuell geprüft.
|
||||||
|
|
||||||
|
Auch die Release-Suite besteht mit 60/60 Tests. Desktop Debug und Release bauen ohne Warnungen oder Fehler; `git diff --check` ist sauber.
|
||||||
|
|
||||||
|
## Dauerhafte Anzeige gelöschter Buchungen
|
||||||
|
|
||||||
|
Die Historienansicht wird als benutzerweite Einstellung in settings.json gespeichert und beim Öffnen jeder Kontoliste geladen. Sie bleibt über Kontowechsel und Neustarts aktiv, bis der Switch ausgeschaltet wird. „Filter zurücksetzen“ ändert nur Suche und Sortierung. Bestehende Einstellungsdateien ohne diesen Wert starten weiterhin mit ausgeblendeten gelöschten Buchungen.
|
||||||
|
|
||||||
|
62/62 Tests in Debug und Release bestanden. Neue Regressionen prüfen Kontowechsel, Filterreset, Ausschalten, erneutes Laden durch eine neue SettingsService-Instanz und Erhalt der übrigen Einstellungen. Navigationstest wartet auf die tatsächlich geladene Kontokarte. Debug-Desktop-Build ohne Warnungen/Fehler. Der vorhandene Publish-Task wird anschließend mit seinen unveränderten Release-/win-x64-/Einzeldatei-Argumenten ausgeführt. Die bereits vorhandene lokale Anpassung an AccountCard.razor bleibt separat erhalten und fließt in die lokale Veröffentlichung ein.
|
||||||
|
|
||||||
|
Publish-Task erfolgreich abgeschlossen: Publish/Duempelkas-v26.306.exe (69.325.134 Bytes). Die ältere veröffentlichte Version bleibt erhalten. Die veröffentlichte Release-Anwendung wurde nicht gegen die Vereinsdaten gestartet.
|
||||||
|
|
||||||
|
## Builddatum im Über-Dialog
|
||||||
|
|
||||||
|
Neben der Programmversion steht jetzt das Builddatum im Format TT.MM.JJJJ. Der Desktop-Build bettet seinen UTC-Zeitstempel als Assembly-Metadatum ein; die Anzeige benötigt weder einen Dateipfad noch das Änderungsdatum der EXE und bleibt dadurch auch in der veröffentlichten Einzeldatei verfügbar. Debug-Build ohne Warnungen/Fehler; eingebettetes Datum 02.11.2026 aus der erzeugten Desktop-Assembly ausgelesen und geprüft. Debug-Suite: 62/62 Tests bestanden.
|
||||||
|
|
||||||
|
Auch Release: 62/62 Tests bestanden; vorhandener Publish-Task erfolgreich erneut ausgeführt und Publish/Duempelkas-v26.306.exe aktualisiert.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
@inject NavigationManager Navigation
|
@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">
|
<div class="card-body d-flex flex-column justify-content-between pb-0 pt-1 px-2">
|
||||||
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
|
<h4 class="card-title mb-3 mt-0">@Account.Name</h4>
|
||||||
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
|
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-5">
|
||||||
@($"{Account.TotalBalance:N2} €")
|
@($"{Account.TotalBalance:N2} €")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<tr class="@GetRowClass()">
|
<tr class="@GetRowClass()">
|
||||||
<td>@Entry.DisplayId</td>
|
<td data-label="Nummer">@Entry.DisplayId</td>
|
||||||
<td>@Entry.Date.ToString("dd.MM.yyyy")</td>
|
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||||
<td>
|
<td data-label="Beschreibung" class="entry-description">
|
||||||
@Entry.Title
|
<span class="entry-title">@Entry.Title</span>
|
||||||
|
@if (Entry.IsDeleted)
|
||||||
|
{
|
||||||
|
<p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p>
|
||||||
|
}
|
||||||
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
||||||
{
|
{
|
||||||
<small class="text-muted ms-1">
|
<small class="text-muted ms-1">
|
||||||
@@ -10,24 +14,24 @@
|
|||||||
</small>
|
</small>
|
||||||
}
|
}
|
||||||
</td>
|
</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} €")
|
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Aktionen" class="entry-actions">
|
||||||
@if (Entry.IsDeleted)
|
@if (Entry.IsDeleted)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-success btn-sm" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" title="Wiederherstellen">
|
<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"></i>
|
<i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Wiederherstellen
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex gap-1">
|
<div class="entry-action-buttons">
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" title="Bearbeiten">
|
<button type="button" class="btn btn-outline-secondary" title="Buchung bearbeiten" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" title="Löschen">
|
<button type="button" class="btn btn-outline-danger" title="Buchung löschen" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} löschen")">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
|
|||||||
|
|
||||||
public partial class EntryRow
|
public partial class EntryRow
|
||||||
{
|
{
|
||||||
|
[Parameter] public bool Highlighted { get; set; }
|
||||||
|
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -26,6 +28,7 @@ public partial class EntryRow
|
|||||||
private string GetRowClass()
|
private string GetRowClass()
|
||||||
{
|
{
|
||||||
var classes = new List<string>();
|
var classes = new List<string>();
|
||||||
|
if (Highlighted) classes.Add("entry-recent");
|
||||||
if (Entry.IsTransfer)
|
if (Entry.IsTransfer)
|
||||||
{
|
{
|
||||||
classes.Add("entry-row-transfer");
|
classes.Add("entry-row-transfer");
|
||||||
|
|||||||
@@ -1,25 +1,50 @@
|
|||||||
<div class="table-responsive">
|
@inject ISettingsService Settings
|
||||||
|
|
||||||
|
<div class="entry-filters">
|
||||||
|
<div><label for="entry-search">Buchungen suchen</label>
|
||||||
|
<input id="entry-search" type="search" class="form-control" @bind="searchText" @bind:event="oninput" placeholder="Beschreibung, Nummer, Datum oder Betrag" /></div>
|
||||||
|
<div><label for="entry-sort">Sortierung</label>
|
||||||
|
<select id="entry-sort" @bind="sort">
|
||||||
|
<option value="date-asc">Datum: älteste zuerst</option><option value="date-desc">Datum: neueste zuerst</option>
|
||||||
|
<option value="amount-asc">Betrag: aufsteigend</option><option value="amount-desc">Betrag: absteigend</option>
|
||||||
|
<option value="title">Beschreibung: A–Z</option>
|
||||||
|
</select></div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary filter-reset" @onclick="ResetFilters">Filter zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(searchText))
|
||||||
|
{
|
||||||
|
<p class="search-summary" role="status">@VisibleEntries.Count Treffer. Summe der wirksamen Treffer: @ResultSum.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) €.
|
||||||
|
Der Kontostand des gewählten Zeitraums oben bleibt unverändert.</p>
|
||||||
|
}
|
||||||
|
@if (Entries.Any(e => e.IsDeleted))
|
||||||
|
{
|
||||||
|
<div class="form-check form-switch history-toggle">
|
||||||
|
<input id="show-deleted" class="form-check-input" type="checkbox" role="switch" @bind="showDeleted" @bind:after="SaveHistoryPreference" />
|
||||||
|
<label class="form-check-label" for="show-deleted">Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="table-responsive entry-list">
|
||||||
<table class="table table-hover table-sm align-middle">
|
<table class="table table-hover table-sm align-middle">
|
||||||
<thead class="table-light">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 100px;">LfdNr</th>
|
<th style="width: 100px;">LfdNr</th>
|
||||||
<th style="width: 120px;">Datum</th>
|
<th style="width: 120px;">Datum</th>
|
||||||
<th>Bezeichnung</th>
|
<th>Bezeichnung</th>
|
||||||
<th style="width: 140px;" class="text-end">Betrag</th>
|
<th style="width: 140px;" class="text-end">Betrag</th>
|
||||||
<th style="width: 80px;"></th>
|
<th style="width: 11rem;">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">@(string.IsNullOrWhiteSpace(searchText) ? EmptyMessage : "Keine Treffer. Filter zurücksetzen zeigt wieder die Buchungen des gewählten Zeitraums.")</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ namespace Duempelkas.App.Components.Accounts;
|
|||||||
|
|
||||||
public partial class EntryTable
|
public partial class EntryTable
|
||||||
{
|
{
|
||||||
|
private bool showDeleted;
|
||||||
|
protected override async Task OnInitializedAsync() => showDeleted = await Settings.GetShowDeletedEntriesAsync();
|
||||||
|
private Task SaveHistoryPreference() => Settings.SetShowDeletedEntriesAsync(showDeleted);
|
||||||
|
private string searchText = string.Empty;
|
||||||
|
private string sort = "date-asc";
|
||||||
|
private List<EntryDto> VisibleEntries => Duempelkas.App.Services.EntryListQuery.Apply(Entries, searchText, showDeleted, sort);
|
||||||
|
private decimal ResultSum => VisibleEntries.Where(e => !e.IsDeleted).Sum(Duempelkas.App.Services.EntryListQuery.SignedAmount);
|
||||||
|
private void ResetFilters() { searchText = string.Empty; sort = "date-asc"; }
|
||||||
|
[Parameter] public int? HighlightedEntryId { get; set; }
|
||||||
|
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<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" />
|
||||||
|
|
||||||
|
@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 async Task SetValue(string value)
|
||||||
|
{
|
||||||
|
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ValueChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@inherits ErrorBoundary
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ToastService Toasts
|
||||||
|
|
||||||
|
@if (CurrentException == null)
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="error-recovery" role="alert">
|
||||||
|
<h1>Diese Ansicht konnte nicht angezeigt werden</h1>
|
||||||
|
<p>Ein unerwarteter Fehler ist aufgetreten. Eine Speicherung ist dadurch nicht bestätigt. Bitte vor einer erneuten Buchung zuerst die Liste prüfen.</p>
|
||||||
|
<p>Ungespeicherte Eingaben dieser Ansicht können verloren sein. Bei erwartbaren Speicherfehlern im Formular bleiben sie erhalten.</p>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="Recover">Ansicht erneut versuchen</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="/">Zur Kontenübersicht</a>
|
||||||
|
<details><summary>Technische Angaben zur Fehlermeldung</summary>
|
||||||
|
<p>@diagnostic</p><button type="button" class="btn btn-outline-secondary" @onclick="Copy">Angaben kopieren</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string diagnostic = string.Empty;
|
||||||
|
protected override Task OnErrorAsync(Exception exception)
|
||||||
|
{
|
||||||
|
diagnostic = $"Dümpelkas; Fehlerreferenz {Guid.NewGuid():N}; Typ {exception.GetType().Name}";
|
||||||
|
// Deliberately exclude exception messages/stack traces that may contain booking data.
|
||||||
|
System.Diagnostics.Trace.TraceError(diagnostic);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
private async Task Copy()
|
||||||
|
{
|
||||||
|
try { await JS.InvokeVoidAsync("navigator.clipboard.writeText", diagnostic); Toasts.Show("Technische Angaben kopiert.", ToastKind.Success); }
|
||||||
|
catch (JSException) { Toasts.Show("Kopieren ist nicht möglich. Bitte die technischen Angaben markieren und manuell kopieren.", ToastKind.Error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<input @attributes="Attributes" id="@Id" type="date" value="@renderedValue" @oninput="Input" @onblur="CommitAsync" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Id { get; set; } = "booking-date";
|
||||||
|
[Parameter] public string Value { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> PendingChangesChanged { get; set; }
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? Attributes { get; set; }
|
||||||
|
private string? lastValue;
|
||||||
|
private string renderedValue = string.Empty;
|
||||||
|
private string draft = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (Value != lastValue) draft = renderedValue = Value;
|
||||||
|
lastValue = Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Input(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
draft = args.Value?.ToString() ?? string.Empty;
|
||||||
|
await PendingChangesChanged.InvokeAsync(draft != Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CommitAsync()
|
||||||
|
{
|
||||||
|
// Native calendars close when their surrounding dialog layout changes. Validate only
|
||||||
|
// after leaving the date field, so year warnings never interrupt calendar navigation.
|
||||||
|
lastValue = draft;
|
||||||
|
await ValueChanged.InvokeAsync(lastValue);
|
||||||
|
await PendingChangesChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
<div class="dialog-backdrop">
|
@inject ToastService Toasts
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
|
||||||
<h5 class="mb-3">Neues Konto</h5>
|
<h5 class="mb-3">Neues Konto</h5>
|
||||||
|
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">Kontoname</label>
|
<label class="form-label" for="account-name">Kontoname</label>
|
||||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput"
|
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||||
placeholder="z.B. Girokonto" />
|
placeholder="z.B. Girokonto" />
|
||||||
</div>
|
</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">
|
<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 type="submit" class="btn btn-success" disabled="@(saving || string.IsNullOrWhiteSpace(name) || !ValidInitial)"><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="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</ModalDialog>
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
|
|||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<string> OnSave { get; set; }
|
public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnCancel { get; set; }
|
public EventCallback OnCancel { get; set; }
|
||||||
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
|
|||||||
#region Fields
|
#region Fields
|
||||||
|
|
||||||
private string name = string.Empty;
|
private string name = string.Empty;
|
||||||
|
private string initialAmount = "0,00";
|
||||||
|
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
|
||||||
|
private bool saving;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -24,13 +27,16 @@ public partial class AddAccountDialog
|
|||||||
|
|
||||||
private async Task Save()
|
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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
|
@inject ToastService Toasts
|
||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && (IsDirty || pendingDateChanges))">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
|
||||||
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
|
|
||||||
|
|
||||||
<div class="form-container">
|
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||||
@if (EditEntry == null)
|
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||||
{
|
<div class="form-container">
|
||||||
<label class="form-label">Art</label>
|
<label class="form-label" for="entry-type">Art</label>
|
||||||
<select @bind="entryType">
|
@if (EditEntry == null)
|
||||||
<option value="@EntryType.Income">Einnahme</option>
|
{
|
||||||
<option value="@EntryType.Expense">Ausgabe</option>
|
<select id="entry-type" @bind="entryType">
|
||||||
</select>
|
<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>
|
<label class="form-label" for="entry-date">Datum</label>
|
||||||
<input type="date" @bind="date" />
|
<div>
|
||||||
|
<BookingDateInput @ref="bookingDate" Id="entry-date" @bind-Value="dateText" PendingChangesChanged="pending => pendingDateChanges = pending" 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>
|
<label class="form-label" for="entry-title">Beschreibung</label>
|
||||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
|
<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>
|
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
<div>
|
||||||
</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">
|
@if (OutsideYear && date != default)
|
||||||
<button class="btn btn-success" @onclick="Save"
|
{
|
||||||
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
|
<div class="alert alert-warning booking-year-warning" role="status">
|
||||||
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
|
<i class="bi bi-exclamation-triangle-fill" aria-hidden="true"></i>
|
||||||
</button>
|
<div><strong>Das Buchungsdatum liegt in einem anderen Jahr.</strong>
|
||||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
<p>Du erfasst die Buchung am @date.ToString("dd.MM.yyyy") und damit außerhalb des angezeigten Jahres @ContextYear. Sie wird im Jahr @date.Year gespeichert und erscheint erst, wenn du dieses Jahr oder „Alle Buchungen“ auswählst.</p>
|
||||||
</div>
|
<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>
|
</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; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
|
||||||
|
|||||||
@@ -15,19 +15,41 @@ public partial class AddEntryDialog
|
|||||||
public EntryDto? EditEntry { get; set; }
|
public EntryDto? EditEntry { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnSave { get; set; }
|
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnCancel { get; set; }
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
#endregion
|
#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
|
#region Fields
|
||||||
|
|
||||||
private EntryType entryType = EntryType.Income;
|
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 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
|
#endregion
|
||||||
|
|
||||||
@@ -35,13 +57,17 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
|
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||||
|
initialized = true;
|
||||||
|
initializedEntryId = EditEntry?.Id;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
entryType = EditEntry.Type;
|
entryType = EditEntry.Type;
|
||||||
date = EditEntry.Date;
|
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amount = EditEntry.Amount;
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
|
initialState = CurrentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -50,19 +76,44 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (EditEntry != null)
|
if (isSaving || completed) return;
|
||||||
|
if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||||
await OnSave.InvokeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
private async Task Cancel()
|
||||||
|
{
|
||||||
|
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,64 @@
|
|||||||
|
@inject ToastService Toasts
|
||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && (IsDirty || pendingDateChanges))">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
||||||
|
|
||||||
<div class="form-container">
|
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
||||||
<label class="form-label">Zielkonto</label>
|
<p id="transfer-direction"><strong>Von: @FromName → Nach: @ToName</strong></p>
|
||||||
<select class="form-select" @bind="targetAccountId">
|
<p>@CurrentAccountName bleibt fest. @(IsIncoming ? "Das abgebende Konto ist änderbar." : "Das empfangende Konto ist änderbar.")</p>
|
||||||
<option value="0">— Auswählen —</option>
|
@if (accounts.Count < 2)
|
||||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
{
|
||||||
|
<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>
|
||||||
|
<BookingDateInput @ref="bookingDate" Id="transfer-date" class="form-control" @bind-Value="dateText" PendingChangesChanged="pending => pendingDateChanges = pending" />
|
||||||
|
|
||||||
|
<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 && date != default)
|
||||||
{
|
{
|
||||||
<option value="@acc.Id">@acc.Name</option>
|
<div class="alert alert-warning booking-year-warning" role="status">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" aria-hidden="true"></i>
|
||||||
|
<div><strong>Das Buchungsdatum liegt in einem anderen Jahr.</strong>
|
||||||
|
<p>Du erfasst die Buchung am @date.ToString("dd.MM.yyyy") und damit außerhalb des angezeigten Jahres @ContextYear. Sie wird im Jahr @date.Year gespeichert und erscheint erst, wenn du dieses Jahr oder „Alle Buchungen“ auswählst.</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</select>
|
|
||||||
|
|
||||||
<label class="form-label">Datum</label>
|
</fieldset>
|
||||||
<input type="date" class="form-control" @bind="date" />
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||||
<label class="form-label">Bezeichnung</label>
|
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||||
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
<label class="form-label">Betrag (€)</label>
|
</div>
|
||||||
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
|
</form>
|
||||||
</div>
|
</ModalDialog>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
|
||||||
|
|||||||
@@ -14,26 +14,52 @@ public partial class AddTransferDialog
|
|||||||
public EntryDto? EditEntry { get; set; }
|
public EntryDto? EditEntry { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnSave { get; set; }
|
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnCancel { get; set; }
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
#endregion
|
#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
|
#region Fields
|
||||||
|
|
||||||
private List<AccountSummaryDto> accounts = new();
|
private List<AccountSummaryDto> accounts = new();
|
||||||
private int targetAccountId;
|
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 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
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#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
|
#endregion
|
||||||
|
|
||||||
@@ -46,13 +72,17 @@ public partial class AddTransferDialog
|
|||||||
accounts = await AccountService.GetAllAccountsAsync();
|
accounts = await AccountService.GetAllAccountsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialized && initializedEntryId == EditEntry?.Id) return;
|
||||||
|
initialized = true;
|
||||||
|
initializedEntryId = EditEntry?.Id;
|
||||||
if (EditEntry != null)
|
if (EditEntry != null)
|
||||||
{
|
{
|
||||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||||
date = EditEntry.Date;
|
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
|
||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amount = EditEntry.Amount;
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
|
initialState = CurrentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -61,19 +91,44 @@ public partial class AddTransferDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (EditEntry != null)
|
if (isSaving || completed) return;
|
||||||
|
if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||||
await OnSave.InvokeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
private async Task Cancel()
|
||||||
|
{
|
||||||
|
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" Busy="Busy">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>@Title</h5>
|
<h5>@Title</h5>
|
||||||
<p>@Message</p>
|
<p>@Message</p>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
<button type="button" disabled="@Busy" 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="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs;
|
|||||||
|
|
||||||
public partial class ConfirmDialog
|
public partial class ConfirmDialog
|
||||||
{
|
{
|
||||||
|
[Parameter] public bool Busy { get; set; }
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -34,9 +35,9 @@ public partial class ConfirmDialog
|
|||||||
|
|
||||||
#region Actions
|
#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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<div class="dialog-backdrop">
|
@inject ToastService Toasts
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
<ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amountText != CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")))" Busy="saving">
|
||||||
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
<h5>Bestandskorrektur – @AccountName</h5>
|
||||||
<div class="form-container">
|
<p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
|
||||||
<label class="form-label">Übertrag (€)</label>
|
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
|
||||||
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
|
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
|
||||||
</div>
|
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<AmountInput Id="carryover" Value="@amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
|
||||||
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
|
<div class="correction-preview">
|
||||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
|
||||||
</div>
|
<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>
|
||||||
</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 Microsoft.AspNetCore.Components;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
namespace Duempelkas.App.Components.Dialogs;
|
namespace Duempelkas.App.Components.Dialogs;
|
||||||
|
|
||||||
public partial class EditCarryoverDialog
|
public partial class EditCarryoverDialog
|
||||||
{
|
{
|
||||||
#region Parameters
|
[Parameter] public decimal CurrentAmount { get; set; }
|
||||||
|
[Parameter] public decimal InitialBalance { get; set; }
|
||||||
[Parameter]
|
[Parameter] public decimal CurrentEndBalance { get; set; }
|
||||||
public decimal CurrentAmount { get; set; }
|
[Parameter] public int? Year { get; set; }
|
||||||
|
[Parameter] public string AccountName { get; set; } = "Konto";
|
||||||
[Parameter]
|
[Parameter] public EventCallback<decimal> OnSave { get; set; }
|
||||||
public EventCallback<decimal> OnSave { get; set; }
|
[Parameter] public EventCallback OnCancel { get; set; }
|
||||||
|
private string amountText = string.Empty;
|
||||||
[Parameter]
|
private bool acknowledged;
|
||||||
public EventCallback OnCancel { get; set; }
|
private bool initialized;
|
||||||
|
private bool saving;
|
||||||
#endregion
|
private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
|
||||||
|
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
|
||||||
#region Fields
|
private decimal Delta => amount - CurrentAmount;
|
||||||
|
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||||
private decimal amount;
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
#endregion
|
if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
|
||||||
|
}
|
||||||
#region Lifecycle
|
private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
|
||||||
|
private async Task Save()
|
||||||
protected override void OnParametersSet() => amount = CurrentAmount;
|
{
|
||||||
|
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
|
||||||
#endregion
|
saving = true;
|
||||||
|
try { await OnSave.InvokeAsync(amount); }
|
||||||
#region Actions
|
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 async Task Save() => await OnSave.InvokeAsync(amount);
|
}
|
||||||
|
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
|
||||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">@DialogTitle</h5>
|
<h5 class="mb-3">@DialogTitle</h5>
|
||||||
|
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">@NameLabel</label>
|
<label class="form-label" for="edit-name">@NameLabel</label>
|
||||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
<input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<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 type="submit" class="btn btn-success" 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="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</ModalDialog>
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ public partial class EditNameDialog
|
|||||||
|
|
||||||
#region Lifecycle
|
#region Lifecycle
|
||||||
|
|
||||||
protected override void OnParametersSet() => name = CurrentName;
|
private bool initialized;
|
||||||
|
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
|
||||||
|
|
||||||
#endregion
|
#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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@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();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
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,34 +1,45 @@
|
|||||||
|
@inject NavigationManager Navigation
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="d-flex flex-column vh-100">
|
<ToastHost />
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||||
<a class="navbar-brand" href="">
|
<a class="navbar-brand" href="">
|
||||||
<strong>Dümpelkas</strong> <small class="text-muted">Kassenbuch</small>
|
<strong>Dümpelkas</strong> <small class="text-muted">Kassenbuch</small>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="reading-controls">
|
||||||
|
<a class="btn btn-outline-secondary" href="hilfe">Hilfe</a>
|
||||||
|
<label for="text-scale">Textgröße</label>
|
||||||
|
<select id="text-scale" @bind="fontScale" @bind:after="ApplyTextScale">
|
||||||
|
<option value="75">Klein (75 %)</option><option value="100">Normal</option><option value="125">Groß (125 %)</option>
|
||||||
|
<option value="150">Sehr groß (150 %)</option><option value="200">200 %</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="flex-grow-1 overflow-auto p-3">
|
<main class="flex-grow-1 overflow-auto p-3">
|
||||||
@Body
|
<AppErrorBoundary @key="Navigation.Uri">@Body</AppErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="app-footer d-flex align-items-center justify-content-center gap-2">
|
<footer class="app-footer d-flex align-items-center justify-content-center gap-2">
|
||||||
<span>Dümpelkas © @DateTime.Now.Year · Version @AppVersion</span> ·<a class="app-footer-about" @onclick="OpenAboutDialog">Über</a>
|
<span>Dümpelkas © @DateTime.Now.Year · Version @AppVersion</span> ·<button type="button" class="app-footer-about btn btn-link" @onclick="OpenAboutDialog">Über</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showAboutDialog)
|
@if (showAboutDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
||||||
<p class="mb-2">Entwickler: Andre Beging</p>
|
<p class="mb-1 mt-3">Erstellt: @AppBuildDate</p>
|
||||||
|
<p class="mb-1">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>
|
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAboutDialog">
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAboutDialog">
|
||||||
<i class="bi bi-x-lg"></i> Schließen
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Duempelkas.App.Components.Layout;
|
namespace Duempelkas.App.Components.Layout;
|
||||||
|
|
||||||
public partial class MainLayout
|
public partial class MainLayout
|
||||||
{
|
{
|
||||||
|
private int fontScale = 100;
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
var value = await JS.InvokeAsync<int>("duempelkasReading.get");
|
||||||
|
fontScale = value is 75 or 125 or 150 or 200 ? value : 100;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async Task ApplyTextScale() => await JS.InvokeVoidAsync("duempelkasReading.set", fontScale);
|
||||||
#region Fields
|
#region Fields
|
||||||
|
|
||||||
private bool showAboutDialog;
|
private bool showAboutDialog;
|
||||||
@@ -12,6 +24,19 @@ public partial class MainLayout
|
|||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
|
|
||||||
|
private static string AppBuildDate
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var value = Assembly.GetEntryAssembly()?.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||||
|
.FirstOrDefault(attribute => attribute.Key == "BuildDate")?.Value;
|
||||||
|
return DateTimeOffset.TryParse(value, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AssumeUniversal, out var built)
|
||||||
|
? built.UtcDateTime.ToString("dd.MM.yyyy", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
: "unbekannt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string AppVersion
|
private static string AppVersion
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
</Found>
|
</Found>
|
||||||
<NotFound>
|
<NotFound>
|
||||||
<LayoutView Layout="@typeof(MainLayout)">
|
<LayoutView Layout="@typeof(MainLayout)">
|
||||||
<p>Seite nicht gefunden.</p>
|
<h1>Diese Seite wurde nicht gefunden</h1>
|
||||||
|
<a class="btn btn-primary" href="/">Zur Kontenübersicht</a>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
</NotFound>
|
</NotFound>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
|
@inject ToastService Toasts
|
||||||
|
@implements IDisposable
|
||||||
|
@inject ReportingPeriod Period
|
||||||
@page "/accounts/{AccountId:int}"
|
@page "/accounts/{AccountId:int}"
|
||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
@@ -7,6 +10,8 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
<OperationNotice Result="fileResult" />
|
||||||
|
|
||||||
@if (account == null)
|
@if (account == null)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
@@ -17,39 +22,38 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
||||||
<h1 class="mb-0 mt-0" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
<h1 class="mb-0 mt-0 page-heading">
|
||||||
@account.Name
|
@account.Name
|
||||||
<button class="btn-edit-pen" @onclick="() => showEditName = true" title="Name bearbeiten"><i class="bi bi-pencil"></i></button>
|
<button class="btn-edit-pen" @onclick="() => showEditName = true" title="Name bearbeiten"><i class="bi bi-pencil"></i></button>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
||||||
|
|
|
||||||
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
|
<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>
|
||||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
<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>
|
||||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
|
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
|
||||||
@if (balance != null)
|
@if (balance != null)
|
||||||
{
|
{
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<div class="summary-flex">
|
<div class="summary-flex">
|
||||||
<div>
|
<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")">
|
<div class="fs-3 fw-bold @(balance.TotalBalance >= 0 ? "amount-positive" : "amount-negative")">
|
||||||
@FormatAmount(balance.TotalBalance)
|
@FormatAmount(balance.TotalBalance)
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<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")">
|
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
|
||||||
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
|
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
@if (entries != null)
|
@if (entries != null)
|
||||||
{
|
{
|
||||||
<div class="entries-scroll-wrapper">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,14 +105,14 @@
|
|||||||
|
|
||||||
@if (showAddEntry)
|
@if (showAddEntry)
|
||||||
{
|
{
|
||||||
<AddEntryDialog AccountId="AccountId"
|
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||||
OnSave="HandleEntryCreated"
|
OnSave="HandleEntryCreated"
|
||||||
OnCancel="() => showAddEntry = false" />
|
OnCancel="() => showAddEntry = false" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showAddTransfer)
|
@if (showAddTransfer)
|
||||||
{
|
{
|
||||||
<AddTransferDialog SourceAccountId="AccountId"
|
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||||
OnSave="HandleTransferCreated"
|
OnSave="HandleTransferCreated"
|
||||||
OnCancel="() => showAddTransfer = false" />
|
OnCancel="() => showAddTransfer = false" />
|
||||||
}
|
}
|
||||||
@@ -122,7 +126,7 @@
|
|||||||
|
|
||||||
@if (showEditCarryover)
|
@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"
|
OnSave="HandleSaveCarryover"
|
||||||
OnCancel="() => showEditCarryover = false" />
|
OnCancel="() => showEditCarryover = false" />
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,7 @@
|
|||||||
@if (confirmDeleteEntryId.HasValue)
|
@if (confirmDeleteEntryId.HasValue)
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="Buchung löschen"
|
<ConfirmDialog Title="Buchung löschen"
|
||||||
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")"
|
Message="@(EntryChangeMessage(confirmDeleteEntryId, false))" Busy="entryActionBusy"
|
||||||
ConfirmText="Ja, löschen"
|
ConfirmText="Ja, löschen"
|
||||||
CancelText="Nein, abbrechen"
|
CancelText="Nein, abbrechen"
|
||||||
ConfirmButtonClass="btn btn-danger"
|
ConfirmButtonClass="btn btn-danger"
|
||||||
@@ -142,7 +146,7 @@
|
|||||||
@if (confirmRestoreEntryId.HasValue)
|
@if (confirmRestoreEntryId.HasValue)
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="Buchung wiederherstellen"
|
<ConfirmDialog Title="Buchung wiederherstellen"
|
||||||
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")"
|
Message="@(EntryChangeMessage(confirmRestoreEntryId, true))" Busy="entryActionBusy"
|
||||||
ConfirmText="Ja, wiederherstellen"
|
ConfirmText="Ja, wiederherstellen"
|
||||||
CancelText="Nein, abbrechen"
|
CancelText="Nein, abbrechen"
|
||||||
ConfirmButtonClass="btn btn-warning"
|
ConfirmButtonClass="btn btn-warning"
|
||||||
@@ -154,7 +158,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="PDF öffnen"
|
<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"
|
ConfirmText="Ja, öffnen"
|
||||||
CancelText="Nein"
|
CancelText="Nein"
|
||||||
ConfirmButtonClass="btn btn-primary"
|
ConfirmButtonClass="btn btn-primary"
|
||||||
@@ -165,7 +169,7 @@
|
|||||||
|
|
||||||
@if (editingEntry != null)
|
@if (editingEntry != null)
|
||||||
{
|
{
|
||||||
<AddEntryDialog AccountId="AccountId"
|
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
|
||||||
EditEntry="editingEntry"
|
EditEntry="editingEntry"
|
||||||
OnSave="HandleEntryEdited"
|
OnSave="HandleEntryEdited"
|
||||||
OnCancel="() => editingEntry = null" />
|
OnCancel="() => editingEntry = null" />
|
||||||
@@ -173,7 +177,7 @@
|
|||||||
|
|
||||||
@if (editingTransferEntry != null)
|
@if (editingTransferEntry != null)
|
||||||
{
|
{
|
||||||
<AddTransferDialog SourceAccountId="AccountId"
|
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
|
||||||
EditEntry="editingTransferEntry"
|
EditEntry="editingTransferEntry"
|
||||||
OnSave="HandleEntryEdited"
|
OnSave="HandleEntryEdited"
|
||||||
OnCancel="() => editingTransferEntry = null" />
|
OnCancel="() => editingTransferEntry = null" />
|
||||||
@@ -181,8 +185,7 @@
|
|||||||
|
|
||||||
@if (showYearFilterDialog)
|
@if (showYearFilterDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Filter auswählen</h5>
|
<h5>Filter auswählen</h5>
|
||||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||||
<div class="filter-options-grid">
|
<div class="filter-options-grid">
|
||||||
@@ -201,7 +204,6 @@
|
|||||||
<i class="bi bi-x-lg"></i> Schließen
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Duempelkas.App.Services;
|
||||||
using Duempelkas.App.Services.Models;
|
using Duempelkas.App.Services.Models;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
@@ -5,11 +6,17 @@ namespace Duempelkas.App.Pages.Accounts;
|
|||||||
|
|
||||||
public partial class AccountDetail
|
public partial class AccountDetail
|
||||||
{
|
{
|
||||||
|
private bool disposed;
|
||||||
|
public void Dispose() => disposed = true;
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public int AccountId { get; set; }
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "jahr")]
|
||||||
|
public int? QueryYear { get; set; }
|
||||||
|
private int? previousQueryYear;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Fields
|
#region Fields
|
||||||
@@ -22,9 +29,11 @@ public partial class AccountDetail
|
|||||||
private bool showEditName;
|
private bool showEditName;
|
||||||
private bool showEditCarryover;
|
private bool showEditCarryover;
|
||||||
private bool showYearFilterDialog;
|
private bool showYearFilterDialog;
|
||||||
private int? selectedYear = DateTime.Now.Year;
|
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||||
private List<int> availableYears = [];
|
private List<int> availableYears = [];
|
||||||
|
|
||||||
|
private bool entryActionBusy;
|
||||||
|
private Guid? undoToastId;
|
||||||
private int? confirmDeleteEntryId;
|
private int? confirmDeleteEntryId;
|
||||||
private string? confirmDeleteEntryTitle;
|
private string? confirmDeleteEntryTitle;
|
||||||
|
|
||||||
@@ -32,7 +41,12 @@ public partial class AccountDetail
|
|||||||
private string? confirmRestoreEntryTitle;
|
private string? confirmRestoreEntryTitle;
|
||||||
|
|
||||||
private string? savedPdfPath;
|
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? editingEntry;
|
||||||
private EntryDto? editingTransferEntry;
|
private EntryDto? editingTransferEntry;
|
||||||
|
|
||||||
@@ -48,6 +62,8 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
if (QueryYear.HasValue && QueryYear != previousQueryYear) selectedYear = QueryYear;
|
||||||
|
previousQueryYear = QueryYear;
|
||||||
await LoadAll();
|
await LoadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +76,9 @@ public partial class AccountDetail
|
|||||||
account = await AccountService.GetAccountAsync(AccountId);
|
account = await AccountService.GetAccountAsync(AccountId);
|
||||||
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
|
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
|
||||||
entries = await EntryService.GetEntriesAsync(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()
|
private void OpenYearFilterDialog()
|
||||||
@@ -76,6 +94,7 @@ public partial class AccountDetail
|
|||||||
private async Task SelectFilterYear(int? year)
|
private async Task SelectFilterYear(int? year)
|
||||||
{
|
{
|
||||||
selectedYear = year;
|
selectedYear = year;
|
||||||
|
savedOutsideYear = null;
|
||||||
showYearFilterDialog = false;
|
showYearFilterDialog = false;
|
||||||
await LoadAll();
|
await LoadAll();
|
||||||
}
|
}
|
||||||
@@ -93,22 +112,34 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private async Task HandleSaveCarryover(decimal newAmount)
|
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;
|
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;
|
showAddEntry = false;
|
||||||
await LoadAll();
|
showAddTransfer = false;
|
||||||
|
editingEntry = null;
|
||||||
|
editingTransferEntry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleTransferCreated()
|
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
|
||||||
{
|
|
||||||
showAddTransfer = false;
|
|
||||||
await LoadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RequestDeleteEntry(int entryId)
|
private void RequestDeleteEntry(int entryId)
|
||||||
{
|
{
|
||||||
@@ -118,19 +149,27 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private void CancelDeleteConfirm()
|
private void CancelDeleteConfirm()
|
||||||
{
|
{
|
||||||
|
if (entryActionBusy) return;
|
||||||
confirmDeleteEntryId = null;
|
confirmDeleteEntryId = null;
|
||||||
confirmDeleteEntryTitle = null;
|
confirmDeleteEntryTitle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleConfirmDelete()
|
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;
|
confirmDeleteEntryId = null;
|
||||||
confirmDeleteEntryTitle = null;
|
|
||||||
await LoadAll();
|
await LoadAll();
|
||||||
|
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||||
|
undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success,
|
||||||
|
actionLabel: "Rückgängig", action: CreateUndoAction(AccountId, 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)
|
private void RequestRestoreEntry(int entryId)
|
||||||
@@ -141,19 +180,64 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private void CancelRestoreConfirm()
|
private void CancelRestoreConfirm()
|
||||||
{
|
{
|
||||||
|
if (entryActionBusy) return;
|
||||||
confirmRestoreEntryId = null;
|
confirmRestoreEntryId = null;
|
||||||
confirmRestoreEntryTitle = null;
|
confirmRestoreEntryTitle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleConfirmRestore()
|
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;
|
confirmRestoreEntryId = null;
|
||||||
confirmRestoreEntryTitle = null;
|
|
||||||
await LoadAll();
|
await LoadAll();
|
||||||
|
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||||
|
undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success,
|
||||||
|
actionLabel: "Rückgängig", action: CreateUndoAction(AccountId, 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 Func<Task> CreateUndoAction(int accountId, int id, bool restore) => () => UndoEntryChange(accountId, id, restore);
|
||||||
|
|
||||||
|
private async Task UndoEntryChange(int accountId, 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);
|
||||||
|
if (!disposed)
|
||||||
|
{
|
||||||
|
await LoadAll();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
else Navigation.NavigateTo($"/accounts/{accountId}");
|
||||||
|
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)
|
private void RequestEditEntry(int entryId)
|
||||||
@@ -169,28 +253,45 @@ public partial class AccountDetail
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleEntryEdited()
|
private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved);
|
||||||
{
|
|
||||||
editingEntry = null;
|
|
||||||
editingTransferEntry = null;
|
|
||||||
await LoadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleExport()
|
private async Task HandleExport()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
var busyToast = Toasts.Show("Kontoauszug wird als PDF erstellt und gespeichert … Bitte warten.", persistent: true);
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
||||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
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()
|
private async Task HandleOpenSavedPdf()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
var busyToast = Toasts.Show("Gespeicherte PDF-Datei wird geöffnet … Bitte warten.", persistent: true);
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
savedPdfPath = null;
|
savedPdfPath = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelOpenSavedPdf()
|
private void CancelOpenSavedPdf()
|
||||||
@@ -202,9 +303,9 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
#region Helpers
|
#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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@inject ToastService Toasts
|
||||||
|
@inject ReportingPeriod Period
|
||||||
@page "/"
|
@page "/"
|
||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
@inject IBackupService BackupService
|
@inject IBackupService BackupService
|
||||||
@@ -8,10 +10,12 @@
|
|||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
<OperationNotice Result="fileResult" />
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
||||||
<h1 class="mb-0 mt-0" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
<h1 class="mb-0 mt-0 page-heading">
|
||||||
@DisplayClubName
|
@DisplayClubName
|
||||||
<button class="btn-edit-pen" @onclick="() => showEditClubName = true" title="Vereinsname bearbeiten">
|
<button class="btn-edit-pen" @onclick="() => showEditClubName = true" title="Vereinsname bearbeiten">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
@@ -20,46 +24,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
||||||
|
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
|
<i class="bi bi-save"></i> <span>Sicherung<br /> erstellen</span>
|
||||||
<i class="bi bi-save"></i> Backup
|
|
||||||
</button>
|
</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>
|
||||||
|
|
|
||||||
<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">
|
<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>
|
||||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (accounts != null && accounts.Any())
|
@if (accounts != null && accounts.Any())
|
||||||
{
|
{
|
||||||
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
<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")">
|
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||||
@FormatCurrency(TotalClubBalance)
|
@FormatCurrency(TotalClubBalance)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
|
||||||
{
|
|
||||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
|
||||||
@operationMessage
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (accounts == null)
|
@if (accounts == null)
|
||||||
{
|
{
|
||||||
@@ -72,6 +71,7 @@
|
|||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
<h4>Noch keine Konten vorhanden</h4>
|
<h4>Noch keine Konten vorhanden</h4>
|
||||||
<p>Erstelle dein erstes Konto, um loszulegen.</p>
|
<p>Erstelle dein erstes Konto, um loszulegen.</p>
|
||||||
|
<a class="btn btn-primary" href="einstieg">Kurze Einführung starten oder fortsetzen</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="PDF öffnen"
|
<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"
|
ConfirmText="Ja, öffnen"
|
||||||
CancelText="Nein"
|
CancelText="Nein"
|
||||||
ConfirmButtonClass="btn btn-primary"
|
ConfirmButtonClass="btn btn-primary"
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
|
|
||||||
@if (showRestoreConfirm)
|
@if (showRestoreConfirm)
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="Restore bestätigen"
|
<ConfirmDialog Title="Sicherung zurückspielen"
|
||||||
Message="Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?"
|
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"
|
ConfirmText="Ja, wiederherstellen"
|
||||||
CancelText="Nein, abbrechen"
|
CancelText="Nein, abbrechen"
|
||||||
ConfirmButtonClass="btn btn-warning"
|
ConfirmButtonClass="btn btn-warning"
|
||||||
@@ -117,8 +117,7 @@
|
|||||||
|
|
||||||
@if (showYearFilterDialog)
|
@if (showYearFilterDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Filter auswählen</h5>
|
<h5>Filter auswählen</h5>
|
||||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||||
<div class="filter-options-grid">
|
<div class="filter-options-grid">
|
||||||
@@ -137,7 +136,6 @@
|
|||||||
<i class="bi bi-x-lg"></i> Schließen
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ public partial class Dashboard
|
|||||||
private bool showEditClubName;
|
private bool showEditClubName;
|
||||||
private bool showRestoreConfirm;
|
private bool showRestoreConfirm;
|
||||||
private bool showYearFilterDialog;
|
private bool showYearFilterDialog;
|
||||||
private int? selectedYear = DateTime.Now.Year;
|
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||||
private List<int> availableYears = [];
|
private List<int> availableYears = [];
|
||||||
private string clubName = string.Empty;
|
private string clubName = string.Empty;
|
||||||
private string? operationMessage;
|
|
||||||
private string operationMessageClass = "alert-info";
|
|
||||||
private string? savedPdfPath;
|
private string? savedPdfPath;
|
||||||
|
private FileOperationResult? fileResult;
|
||||||
|
private bool fileBusy;
|
||||||
private string currentTheme = "light";
|
private string currentTheme = "light";
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -67,7 +67,7 @@ public partial class Dashboard
|
|||||||
private async Task LoadAll()
|
private async Task LoadAll()
|
||||||
{
|
{
|
||||||
await LoadAccounts();
|
await LoadAccounts();
|
||||||
availableYears = await EntryService.GetAllEntryYearsAsync();
|
availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAccounts()
|
private async Task LoadAccounts()
|
||||||
@@ -84,9 +84,9 @@ public partial class Dashboard
|
|||||||
|
|
||||||
#region Actions
|
#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;
|
showAddAccount = false;
|
||||||
await LoadAll();
|
await LoadAll();
|
||||||
}
|
}
|
||||||
@@ -100,11 +100,17 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleBackupAsync()
|
private async Task HandleBackupAsync()
|
||||||
{
|
{
|
||||||
var message = await BackupService.CreateBackupAsync();
|
if (fileBusy) return;
|
||||||
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
|
var busyToast = Toasts.Show("Sicherung wird erstellt … 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()
|
private Task HandleRestoreAsync()
|
||||||
@@ -120,25 +126,38 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleConfirmRestore()
|
private async Task HandleConfirmRestore()
|
||||||
{
|
{
|
||||||
showRestoreConfirm = false;
|
if (fileBusy) return;
|
||||||
var message = await BackupService.RestoreBackupAsync();
|
var busyToast = Toasts.Show("Sicherung wird wiederhergestellt … Bitte warten.", persistent: true);
|
||||||
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
|
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()
|
private async Task HandleDashboardExportAsync()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
var busyToast = Toasts.Show("Kontenübersicht wird als PDF erstellt und gespeichert … Bitte warten.", persistent: true);
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
||||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
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()
|
private void OpenYearFilterDialog()
|
||||||
@@ -160,12 +179,22 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleOpenSavedPdf()
|
private async Task HandleOpenSavedPdf()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
var busyToast = Toasts.Show("Gespeicherte PDF-Datei wird geöffnet … Bitte warten.", persistent: true);
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
savedPdfPath = null;
|
savedPdfPath = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelOpenSavedPdf()
|
private void CancelOpenSavedPdf()
|
||||||
@@ -177,18 +206,12 @@ public partial class Dashboard
|
|||||||
|
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
private void SetOperationMessage(string message, bool success)
|
|
||||||
{
|
|
||||||
operationMessage = message;
|
|
||||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCurrency(decimal amount)
|
private static string FormatCurrency(decimal amount)
|
||||||
{
|
{
|
||||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
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";
|
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
@page "/einstieg"
|
||||||
|
@inject ISettingsService Settings
|
||||||
|
@inject IAccountService Accounts
|
||||||
|
@inject IBackupService Backups
|
||||||
|
@inject ToastService Toasts
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<h1>Erste Schritte</h1>
|
||||||
|
<p>Schritt @(step + 1) von 5. Du kannst die Einführung jederzeit überspringen und über Hilfe fortsetzen.</p>
|
||||||
|
@if (loading) { <p role="status">Einführung wird geladen …</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (step == 0)
|
||||||
|
{
|
||||||
|
<h2>Dein Verein</h2>
|
||||||
|
<label for="setup-club">Vereinsname</label><input id="setup-club" class="form-control" @bind="clubName" @bind:event="oninput" />
|
||||||
|
<button class="btn btn-primary setup-next" disabled="@(busy || string.IsNullOrWhiteSpace(clubName))" @onclick="SaveClub">Speichern und weiter</button>
|
||||||
|
}
|
||||||
|
else if (step == 1)
|
||||||
|
{
|
||||||
|
<h2>Dein erstes Konto</h2>
|
||||||
|
<p>Zum Beispiel Barkasse oder Girokonto. Der Anfangsbestand ist das bereits vorhandene Geld, keine neue Einnahme.</p>
|
||||||
|
<button class="btn btn-primary" @onclick="() => showAccount = true">Konto anlegen</button>
|
||||||
|
@if (accounts.Count > 0)
|
||||||
|
{
|
||||||
|
<p>Ein Konto ist bereits eingerichtet.</p><button class="btn btn-outline-secondary setup-next" @onclick="Next">Vorhandenes Konto verwenden</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (step == 2)
|
||||||
|
{
|
||||||
|
<h2>Die erste Buchung</h2>
|
||||||
|
<p>Wähle ein Konto und erfasse einen tatsächlichen Mitgliedsbeitrag oder eine Ausgabe, etwa Porto. Es werden keine Beispielbuchungen erzeugt.</p>
|
||||||
|
@foreach (var account in accounts) { <p><a href="@($"accounts/{account.Id}")">@account.Name öffnen und buchen</a></p> }
|
||||||
|
<button class="btn btn-primary setup-next" @onclick="Next">Weiter zur Sicherung</button>
|
||||||
|
}
|
||||||
|
else if (step == 3)
|
||||||
|
{
|
||||||
|
<h2>Deine Daten sichern</h2>
|
||||||
|
<p>Sichere die Buchungen zusätzlich auf einem unabhängigen Datenträger. Vereinsname und Darstellung sind nicht enthalten. Zur Einrichtung muss keine Sicherung zurückgespielt werden.</p>
|
||||||
|
<button class="btn btn-primary" disabled="@busy" @onclick="Backup">Sicherung erstellen</button>
|
||||||
|
<OperationNotice Result="backupResult" />
|
||||||
|
<button class="btn btn-outline-secondary setup-next" @onclick="Next">Weiter</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h2>Bereit für das Kassenbuch</h2><p>Die Hilfe bleibt jederzeit erreichbar. Prüfe regelmäßig Kontostände und Sicherungen.</p>
|
||||||
|
<a class="btn btn-primary" href="/">Zur Kontenübersicht</a>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="Restart">Einführung erneut durchgehen</button>
|
||||||
|
}
|
||||||
|
<p><button class="btn btn-outline-secondary" disabled="@busy" @onclick="Skip">Einführung später fortsetzen</button> <a href="hilfe">Zur Hilfe</a></p>
|
||||||
|
}
|
||||||
|
@if (showAccount)
|
||||||
|
{
|
||||||
|
<AddAccountDialog OnSave="CreateAccount" OnCancel="() => showAccount = false" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int step;
|
||||||
|
private bool loading = true, busy, showAccount, advancing;
|
||||||
|
private string clubName = string.Empty;
|
||||||
|
private List<AccountSummaryDto> accounts = [];
|
||||||
|
private FileOperationResult? backupResult;
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
step = Math.Clamp(await Settings.GetSetupStepAsync(), 0, 4);
|
||||||
|
clubName = await Settings.GetClubNameAsync() ?? string.Empty;
|
||||||
|
accounts = await Accounts.GetAllAccountsAsync();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
private async Task Next()
|
||||||
|
{
|
||||||
|
if (advancing) return;
|
||||||
|
advancing = true;
|
||||||
|
try { var next = Math.Min(step + 1, 4); await Settings.SetSetupStepAsync(next); step = next; }
|
||||||
|
catch (Exception ex) { Toasts.Show("Der Fortschritt konnte nicht gespeichert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||||
|
finally { advancing = false; }
|
||||||
|
}
|
||||||
|
private async Task SaveClub()
|
||||||
|
{
|
||||||
|
if (busy || string.IsNullOrWhiteSpace(clubName)) return;
|
||||||
|
busy = true;
|
||||||
|
try { await Settings.SetClubNameAsync(clubName); await Next(); }
|
||||||
|
catch (Exception ex) { Toasts.Show("Der Schritt konnte nicht gespeichert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
private async Task CreateAccount(AccountSetup setup)
|
||||||
|
{
|
||||||
|
await Accounts.CreateAccountAsync(setup.Name, setup.InitialBalance);
|
||||||
|
showAccount = false;
|
||||||
|
accounts = await Accounts.GetAllAccountsAsync();
|
||||||
|
await Next();
|
||||||
|
}
|
||||||
|
private async Task Backup()
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try { backupResult = await Backups.CreateBackupAsync(); }
|
||||||
|
catch (Exception ex) { backupResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
private Task Skip() { Navigation.NavigateTo("/"); return Task.CompletedTask; }
|
||||||
|
private async Task Restart() { step = 0; await Settings.SetSetupStepAsync(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@page "/hilfe"
|
||||||
|
<h1>Hilfe für Dümpelkas</h1>
|
||||||
|
<p>Passend zu dieser Programmversion. Diese Hilfe ist auch ohne Internet verfügbar.</p>
|
||||||
|
<p><a class="btn btn-primary" href="einstieg">Erste Schritte öffnen oder fortsetzen</a> <a href="/">Zur Kontenübersicht</a></p>
|
||||||
|
<details open><summary>Konten einrichten</summary>
|
||||||
|
<p>Auf der Übersicht „Neues Konto“ wählen. Beispiele: Barkasse, Girokonto, Sparbuch. Den tatsächlich vorhandenen Anfangsbestand vor der ersten erfassten Buchung eingeben; 0 oder ein negativer Bestand sind möglich. Es werden keine Beispielbuchungen angelegt.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Einnahme oder Ausgabe erfassen</summary>
|
||||||
|
<p>Konto öffnen, „Einnahme oder Ausgabe erfassen“ wählen, Art, echtes Datum, Beschreibung und Betrag eingeben. Beispiele sind Mitgliedsbeitrag und Porto. Komma und Punkt sind als Dezimaltrenner möglich; das Control verhindert zusätzliche Dezimalstellen und übernimmt ungültige Einfügungen nicht. Beträge ohne Minuszeichen erfassen; die Art legt die Richtung fest.</p>
|
||||||
|
<p>Beim Speichern kurz warten. Ein Toast bestätigt Art und Betrag. Bei Fehlern bleiben Eingaben erhalten. Beim Bearbeiten bleibt die Art fest; eine falsche Art durch Löschen und erneutes Erfassen korrigieren.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Jahr, Kontostand und Anfangsbestand verstehen</summary>
|
||||||
|
<p>„Jahr“ begrenzt Liste und Summen; „Alle Jahre“ zeigt den gesamten Zeitraum. Die Auswahl bleibt beim Navigieren erhalten. Ein abweichendes Buchungsdatum bleibt unverändert und wird vor dem Speichern erklärt. Die Meldung bietet den Aufruf des gespeicherten Jahres.</p>
|
||||||
|
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand. Der Übertrag ist der Bestand vor Beginn des gewählten Jahres. Spätere Bestandskorrekturen ändern den gemeinsamen Anfangsbestand und damit auch frühere und spätere Jahresstände. Vorher/nachher prüfen und erst dann bestätigen; eine fehlende Buchung gewöhnlich als Buchung erfassen.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Geld zwischen Konten verschieben</summary>
|
||||||
|
<p>Die Richtung wird immer als Von → Nach gezeigt, auch beim Öffnen der empfangenen Seite. Das aktuelle Konto ist fest, das Gegenkonto änderbar. Beispiel: Barkasse −50,00 €, Girokonto +50,00 €; die Vereinssumme bleibt gleich. Es wird nur die Buchung erfasst, keine Banküberweisung ausgeführt.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Bearbeiten, Löschen und Wiederherstellen</summary>
|
||||||
|
<p>Das Aktionsmenü der richtigen Zeile öffnen. Bestätigungen nennen Datum, Betrag und Konto; bei Umbuchungen wirken Löschen und Wiederherstellen gemeinsam auf beide Konten. Gelöschte Zeilen sind zunächst ausgeblendet und zählen nicht zum Kontostand. „Gelöschte Buchungen anzeigen“ öffnet die Historie. Der Erfolgstoast bietet für die letzte Änderung „Rückgängig“.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Buchungen finden</summary>
|
||||||
|
<p>Jahr auswählen und die Suche über der Liste verwenden. Sie findet Beschreibung und Buchungsnummer, zusätzlich Datum und Betrag. Die Sortierung kann geändert werden. „Filter zurücksetzen“ zeigt die vollständige Liste des gewählten Jahres. Der Kontostand bleibt unverändert; eine Treffermengensumme ist gesondert beschriftet.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Als PDF speichern</summary>
|
||||||
|
<p>Auf der Kontoansicht wird ein Einzelkonto, auf der Übersicht werden alle Konten exportiert. Der gewählte Zeitraum wird übernommen. Normale PDFs enthalten nur wirksame Buchungen. Umbuchungen sind im Einzelkonto Zu-/Abgänge und in der Gesamtübersicht eine Zeile mit beiden Konten. Der Toast nennt den Speicherort und bietet „Ordner öffnen“.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Sichern und zurückspielen</summary>
|
||||||
|
<p>„Sicherung erstellen“ speichert Konten und Buchungen einschließlich Historie. Vereinsname, Textgröße, Farbdarstellung und Fenstergröße sind nicht enthalten. Eine Kopie auf derselben Festplatte schützt nicht vor Geräteausfall; eine weitere Kopie auf einem unabhängigen Datenträger aufbewahren.</p>
|
||||||
|
<p>„Weitere Aktionen → Sicherung zurückspielen“ ersetzt die aktuellen Konten und Buchungen. Nur passende geprüfte Sicherungen werden angenommen. Vorher legt Dümpelkas eine Rückfallsicherung neben der aktiven Datenbank an; diese wird nicht automatisch gelöscht. Eine alte Sicherung enthält spätere Buchungen nicht. Rückspielen nicht zum Ausprobieren auf dem Vereinsbestand verwenden.</p>
|
||||||
|
</details>
|
||||||
|
<details><summary>Tastatur, Darstellung und Fehler</summary>
|
||||||
|
<p>Tab und Umschalt-Tab wechseln zwischen Bedienelementen. Enter öffnet Links oder speichert passende Formulare. Escape schließt Dialoge; geänderte Eingaben werden vorher abgefangen. Unsichere Bestätigungen beginnen auf Abbrechen.</p>
|
||||||
|
<p>Textgröße steht oben, der Farbmodus in der Übersicht. Meldungen erscheinen oben, verschwinden zeitgesteuert und pausieren bei Maus/Fokus. Bei erwartbaren Speicherfehlern bleibt die Eingabe erhalten. Technische Details sind bei Bedarf aufklappbar. Ein unerwarteter Fehler bestätigt keine Speicherung: vor einem erneuten Eintrag zuerst die Liste prüfen.</p>
|
||||||
|
</details>
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
|
public static class EntryListQuery
|
||||||
|
{
|
||||||
|
public static decimal SignedAmount(EntryDto entry) => entry.Type == EntryType.Income ? entry.Amount : -entry.Amount;
|
||||||
|
public static List<EntryDto> Apply(IEnumerable<EntryDto> entries, string search, bool showDeleted, string sort)
|
||||||
|
{
|
||||||
|
var terms = search.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var de = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
var matches = entries.Where(e => !e.IsDeleted || showDeleted).Where(e =>
|
||||||
|
{
|
||||||
|
var text = $"{e.Title} {e.DisplayId} {e.Date:dd.MM.yyyy} {e.Amount.ToString("N2", de)} {SignedAmount(e).ToString("0.00", de)} {e.LinkedAccountName}";
|
||||||
|
return terms.All(term => text.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||||
|
});
|
||||||
|
return (sort switch
|
||||||
|
{
|
||||||
|
"date-desc" => matches.OrderByDescending(e => e.Date).ThenByDescending(e => e.Id),
|
||||||
|
"amount-asc" => matches.OrderBy(SignedAmount).ThenBy(e => e.Id),
|
||||||
|
"amount-desc" => matches.OrderByDescending(SignedAmount).ThenBy(e => e.Id),
|
||||||
|
"title" => matches.OrderBy(e => e.Title, StringComparer.Create(de, true)).ThenBy(e => e.Id),
|
||||||
|
_ => matches.OrderBy(e => e.Date).ThenBy(e => e.Id)
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ public interface IAccountService
|
|||||||
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
|
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
|
||||||
Task<AccountSummaryDto> GetAccountAsync(int accountId);
|
Task<AccountSummaryDto> GetAccountAsync(int accountId);
|
||||||
Task<AccountSummaryDto> CreateAccountAsync(string name);
|
Task<AccountSummaryDto> CreateAccountAsync(string name);
|
||||||
|
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
|
||||||
Task RenameAccountAsync(int accountId, string newName);
|
Task RenameAccountAsync(int accountId, string newName);
|
||||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
|
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
|
||||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
|
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
|
||||||
|
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
|
||||||
Task DeleteAccountAsync(int accountId);
|
Task DeleteAccountAsync(int accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
|
||||||
namespace Duempelkas.App.Services;
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
public interface IBackupService
|
public interface IBackupService
|
||||||
{
|
{
|
||||||
Task<string> CreateBackupAsync();
|
Task<FileOperationResult> CreateBackupAsync();
|
||||||
Task<string> RestoreBackupAsync();
|
Task<FileOperationResult> RestoreBackupAsync();
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ public interface IEntryService
|
|||||||
Task<List<int>> GetEntryYearsAsync(int accountId);
|
Task<List<int>> GetEntryYearsAsync(int accountId);
|
||||||
Task<List<int>> GetAllEntryYearsAsync();
|
Task<List<int>> GetAllEntryYearsAsync();
|
||||||
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
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 DeleteEntryAsync(int entryId);
|
||||||
Task RestoreEntryAsync(int entryId);
|
Task RestoreEntryAsync(int entryId);
|
||||||
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
|
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;
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
public interface IFileSaveService
|
public interface IFileSaveService
|
||||||
{
|
{
|
||||||
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
|
Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
|
||||||
Task OpenFileAsync(string filePath);
|
Task<FileOperationResult> OpenFileAsync(string filePath);
|
||||||
|
Task<FileOperationResult> OpenFolderAsync(string filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ namespace Duempelkas.App.Services;
|
|||||||
|
|
||||||
public interface ISettingsService
|
public interface ISettingsService
|
||||||
{
|
{
|
||||||
|
Task<bool> GetShowDeletedEntriesAsync();
|
||||||
|
Task SetShowDeletedEntriesAsync(bool show);
|
||||||
|
Task<int> GetSetupStepAsync() => Task.FromResult(0);
|
||||||
|
Task SetSetupStepAsync(int step) => Task.CompletedTask;
|
||||||
Task<string?> GetClubNameAsync();
|
Task<string?> GetClubNameAsync();
|
||||||
Task SetClubNameAsync(string? clubName);
|
Task SetClubNameAsync(string? clubName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<AssemblyMetadata Include="BuildDate" Value="$([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ'))" />
|
||||||
<Content Include="wwwroot\**">
|
<Content Include="wwwroot\**">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ class Program
|
|||||||
var appDataDir = Path.Combine(
|
var appDataDir = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"Duempelkas");
|
"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);
|
Directory.CreateDirectory(appDataDir);
|
||||||
|
|
||||||
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||||
@@ -34,24 +44,31 @@ class Program
|
|||||||
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
// 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 legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||||
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||||
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
if (!isolated && !File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||||
{
|
{
|
||||||
try
|
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");
|
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||||
|
|
||||||
var app = appBuilder.Build();
|
var app = appBuilder.Build();
|
||||||
|
|
||||||
|
// Native toast registration replaces the Windows app identity and creates host shortcuts.
|
||||||
|
// All notifications are displayed inside the application instead.
|
||||||
|
app.MainWindow.SetNotificationsEnabled(false);
|
||||||
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
||||||
|
|
||||||
// Force the native window icon for title bar + taskbar representation.
|
// Force the native window icon for title bar + taskbar representation.
|
||||||
@@ -68,9 +85,7 @@ class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load saved window size or use defaults
|
// Load saved window size or use defaults
|
||||||
var settingsPath = Path.Combine(
|
var settingsPath = Path.Combine(appDataDir, "window.json");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"Duempelkas", "window.json");
|
|
||||||
|
|
||||||
int savedWidth = 1280, savedHeight = 800;
|
int savedWidth = 1280, savedHeight = 800;
|
||||||
if (File.Exists(settingsPath))
|
if (File.Exists(settingsPath))
|
||||||
@@ -86,7 +101,7 @@ class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MainWindow
|
app.MainWindow
|
||||||
.SetTitle("Dümpelkas – Kassenbuch")
|
.SetTitle(isolated ? "Dümpelkas – Testdaten" : "Dümpelkas – Kassenbuch")
|
||||||
.SetSize(savedWidth, savedHeight)
|
.SetSize(savedWidth, savedHeight)
|
||||||
.SetUseOsDefaultSize(false);
|
.SetUseOsDefaultSize(false);
|
||||||
|
|
||||||
@@ -108,7 +123,7 @@ class Program
|
|||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (sender, error) =>
|
AppDomain.CurrentDomain.UnhandledException += (sender, error) =>
|
||||||
{
|
{
|
||||||
app.MainWindow.ShowMessage("Schwerwiegender Fehler", error.ExceptionObject.ToString());
|
app.MainWindow.ShowMessage("Dümpelkas konnte nicht fortgesetzt werden", "Bitte das Programm schließen und neu öffnen. Eine Speicherung ist nicht bestätigt; vor erneutem Buchen die Liste prüfen. Fehlerart: " + error.ExceptionObject.GetType().Name);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -217,8 +217,14 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-nav {
|
.btn-nav {
|
||||||
width: 75px;
|
width: auto;
|
||||||
height: 75px;
|
min-width: 7rem;
|
||||||
|
max-width: 15rem;
|
||||||
|
min-height: 75px;
|
||||||
|
height: auto;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -344,8 +350,9 @@ html[data-ui-theme="light"] .btn-dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table thead th {
|
.table thead th {
|
||||||
|
background-color: var(--color-surface);
|
||||||
border-bottom-color: var(--color-border);
|
border-bottom-color: var(--color-border);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -588,3 +595,123 @@ input[type="number"].no-spinner {
|
|||||||
margin-bottom: 0.3rem;
|
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; } }
|
||||||
|
|
||||||
|
.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 { min-height: 44px; min-width: 44px; padding: .6rem; }
|
||||||
|
.entry-action-buttons { display: flex; gap: .5rem; }
|
||||||
|
.entry-action-buttons .btn { display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.entry-action-buttons .btn i { margin: 0; line-height: 1; }
|
||||||
|
.entry-action-buttons .btn i::before { display: block; }
|
||||||
|
.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 { margin-block: 1rem; min-height: 2rem; display: flex; align-items: center; gap: .75rem; }
|
||||||
|
.history-toggle .form-check-input { flex-shrink: 0; margin-top: 0; }
|
||||||
|
.booking-year-warning { display: flex; gap: .75rem; align-items: flex-start; }
|
||||||
|
.booking-year-warning > i { font-size: 1.4rem; }
|
||||||
|
.booking-year-warning label { display: block; margin-top: .75rem; }
|
||||||
|
dialog.app-modal { scrollbar-gutter: stable; }
|
||||||
|
.entry-deleted { opacity: 1 !important; }
|
||||||
|
.deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; }
|
||||||
|
|
||||||
|
/* Readable theme roles, also used when external styles are unavailable. */
|
||||||
|
:root { --color-bg: #f1f5f9; --color-income: #166534; --color-expense: #b91c1c; --color-transfer: #1d4ed8; --color-text-muted: #475569; --color-border: #94a3b8; }
|
||||||
|
html[data-ui-theme="dark"] { --color-income: #4ade80; --color-expense: #fca5a5; --color-transfer: #93c5fd; --color-text-muted: #cbd5e1; --color-border: #64748b; }
|
||||||
|
html { font-size: var(--app-font-size, 16px); }
|
||||||
|
body { line-height: 1.5; }
|
||||||
|
h1,h2,h3,h4,h5,h6 { font-family: inherit; }
|
||||||
|
small, .text-muted { color: var(--color-text-muted) !important; }
|
||||||
|
small { font-size: .9rem; }
|
||||||
|
.form-label, .table thead th { text-transform: none; letter-spacing: normal; font-size: 1rem; }
|
||||||
|
.app-shell { height: 100dvh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||||
|
.app-navbar, .app-footer { flex-wrap: wrap; gap: .5rem; height: auto; min-height: 2.5rem; }
|
||||||
|
.app-footer { font-size: .9rem; }
|
||||||
|
.reading-controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.reading-controls select { padding: .35rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); }
|
||||||
|
.page-heading, .card-title { overflow-wrap: anywhere; white-space: normal; }
|
||||||
|
.app-modal { box-sizing: border-box; width: min(38rem, calc(100vw - 2rem)); min-width: 0; max-width: calc(100vw - 2rem); max-height: calc(100dvh - 2rem); }
|
||||||
|
.app-modal p, .app-modal label { overflow-wrap: anywhere; }
|
||||||
|
.app-modal .dialog-actions, .app-modal form > .d-flex { flex-wrap: wrap; position: sticky; bottom: -1rem; background: var(--color-surface); padding: .75rem 0; }
|
||||||
|
.form-container { max-width: none; }
|
||||||
|
.btn-primary { background: #4338ca; border-color: #4338ca; color: #fff; }
|
||||||
|
.btn-success { background: #166534; border-color: #166534; color: #fff; }
|
||||||
|
.btn-info { background: #1e40af; border-color: #1e40af; color: #fff; }
|
||||||
|
.btn-danger { background: #b91c1c; border-color: #b91c1c; color: #fff; }
|
||||||
|
.btn-warning { background: #fbbf24; border-color: #fbbf24; color: #111827; }
|
||||||
|
.btn-secondary { background: var(--color-surface-hover); color: var(--color-text); border-color: var(--color-border); }
|
||||||
|
.btn:hover { filter: brightness(.92); }
|
||||||
|
input,select,textarea,button { font: inherit; }
|
||||||
|
button,summary { touch-action: manipulation; }
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.form-container { grid-template-columns: minmax(0, 1fr); gap: .5rem; }
|
||||||
|
.form-container label,.form-container input,.form-container select,.form-container textarea { grid-column: auto; }
|
||||||
|
.action-toolbar { gap: .5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #4338ca; border-color: #4338ca; color: #fff; }
|
||||||
|
.btn-success:hover { background: #166534; border-color: #166534; color: #fff; }
|
||||||
|
.btn-info:hover { background: #1e40af; border-color: #1e40af; color: #fff; }
|
||||||
|
.btn-danger:hover { background: #b91c1c; border-color: #b91c1c; color: #fff; }
|
||||||
|
|
||||||
|
.entry-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; padding: .75rem 0; }
|
||||||
|
.entry-filters > div { flex: 1 1 14rem; min-width: 0; }
|
||||||
|
.entry-filters input,.entry-filters select { width: 100%; box-sizing: border-box; padding: .5rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); }
|
||||||
|
.search-summary { padding: .6rem; border-left: 3px solid var(--color-accent); }
|
||||||
|
|
||||||
|
.text-danger { color: var(--color-expense) !important; }
|
||||||
|
.text-success { color: var(--color-income) !important; }
|
||||||
|
|
||||||
|
.entry-deleted td { text-decoration: none; }
|
||||||
|
.entry-deleted .entry-title, .entry-deleted td[data-label="Betrag"] { text-decoration: line-through; }
|
||||||
|
|||||||
@@ -109,23 +109,20 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<title>Dümpelkas – Kassenbuch</title>
|
<title>Dümpelkas – Kassenbuch</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
<link href="vendor/bootstrap/bootstrap.min.css" rel="stylesheet" />
|
||||||
rel="stylesheet"
|
<link href="vendor/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet" />
|
||||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1nDaGm7AT/+aIi56blBBJoSpcBx5Hn"
|
|
||||||
crossorigin="anonymous" />
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
|
||||||
rel="stylesheet" />
|
|
||||||
<link href="css/app.css" rel="stylesheet" />
|
<link href="css/app.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app>Laden...</app>
|
<app>Laden...</app>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
Ein unbehandelter Fehler ist aufgetreten.
|
Die Ansicht konnte wegen eines unerwarteten Fehlers nicht fortgesetzt werden. Eine Speicherung ist nicht bestätigt; bitte vor erneutem Buchen die Liste prüfen.
|
||||||
<a href="" class="reload">Neu laden</a>
|
<a href="" class="reload">Ansicht neu laden</a>
|
||||||
<a class="dismiss">×</a>
|
<button type="button" class="dismiss" aria-label="Fehlerhinweis schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
<script src="_framework/blazor.webview.js"></script>
|
<script src="_framework/blazor.webview.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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);
|
||||||
|
return allowed(input, next);
|
||||||
|
}
|
||||||
|
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(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.duempelkasReading = {
|
||||||
|
get() { let value = 100; try { value = Number(localStorage.getItem('duempelkas-text-scale')) || 100; } catch (_) {} return this.set(value, false); },
|
||||||
|
set(value, persist = true) {
|
||||||
|
const scale = [75,100,125,150,200].includes(Number(value)) ? Number(value) : 100;
|
||||||
|
document.documentElement.style.setProperty('--app-font-size', (16 * scale / 100) + 'px');
|
||||||
|
if (persist) { try { localStorage.setItem('duempelkas-text-scale', String(scale)); } catch (_) {} }
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.duempelkasReading.get();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Bootstrap 5.3.3 and Bootstrap Icons 1.11.3, MIT-licensed. Vendored from the corresponding npm releases via cdn.jsdelivr.net; original licenses are in each directory. Font query hashes were removed for local WebView paths.
|
||||||
|
|
||||||
|
bootstrap\bootstrap.min.css SHA256 3c8f27e6009ccfd710a905e6dcf12d0ee3c6f2ac7da05b0572d3e0d12e736fc8
|
||||||
|
bootstrap\LICENSE SHA256 8c14611ae41ac6fd543c13349f22188eb12c69b3e59105c5eca3925a8e4eca3e
|
||||||
|
bootstrap-icons\bootstrap-icons.min.css SHA256 ea5f11e3455e7ec8cdfec5a46a6f0905308979d60cb2777c9246bc04cd37cff7
|
||||||
|
bootstrap-icons\LICENSE SHA256 0fb3e11bd57e896c5a512afd64864d28a37de45d19835016c87ca1ad19ead969
|
||||||
|
bootstrap-icons\fonts\bootstrap-icons.woff SHA256 bb1de989b83970f6f4e54de1cd974c5cba55b73582da5e1b225a6d0edf029483
|
||||||
|
bootstrap-icons\fonts\bootstrap-icons.woff2 SHA256 476adf42b40325098fcfa8b36ab3e769186bb4f6ce6a249753e2e1a9c22bf99e
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019-2024 The Bootstrap Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2011-2024 The Bootstrap Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
File diff suppressed because one or more lines are too long
@@ -8,10 +8,11 @@ namespace Duempelkas.Infrastructure;
|
|||||||
|
|
||||||
public static class DependencyInjection
|
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 =>
|
services.AddSingleton(new DatabaseLocation(connectionString));
|
||||||
options.UseSqlite(connectionString));
|
// The factory also registers FinanceDbContext as scoped. Registering
|
||||||
|
// AddDbContext first would give its singleton factory scoped options.
|
||||||
services.AddDbContextFactory<FinanceDbContext>(options =>
|
services.AddDbContextFactory<FinanceDbContext>(options =>
|
||||||
options.UseSqlite(connectionString));
|
options.UseSqlite(connectionString));
|
||||||
|
|
||||||
@@ -19,9 +20,12 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IEntryService, EntryService>();
|
services.AddScoped<IEntryService, EntryService>();
|
||||||
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
||||||
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
||||||
|
services.AddSingleton<IFileDialogService, FileDialogService>();
|
||||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||||
services.AddSingleton<IBackupService, BackupService>();
|
services.AddSingleton<IBackupService, BackupService>();
|
||||||
services.AddSingleton<ISettingsService, SettingsService>();
|
services.AddSingleton<ISettingsService>(new SettingsService(settingsDirectory));
|
||||||
|
services.AddSingleton<ReportingPeriod>();
|
||||||
|
services.AddSingleton<ToastService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
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()
|
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||||
{
|
{
|
||||||
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var accounts = await db.Accounts
|
var accounts = await db.Accounts
|
||||||
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts
|
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);
|
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();
|
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);
|
db.Accounts.Add(account);
|
||||||
await db.SaveChangesAsync();
|
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)
|
public async Task RenameAccountAsync(int accountId, string newName)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -107,6 +121,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
|
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -123,6 +138,7 @@ public class AccountService : IAccountService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -136,8 +152,25 @@ public class AccountService : IAccountService
|
|||||||
await db.SaveChangesAsync();
|
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)
|
public async Task DeleteAccountAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Duempelkas.App.Services;
|
using Duempelkas.App.Services;
|
||||||
@@ -7,140 +8,126 @@ namespace Duempelkas.Infrastructure.Services;
|
|||||||
|
|
||||||
public class BackupService : IBackupService
|
public class BackupService : IBackupService
|
||||||
{
|
{
|
||||||
private static readonly string DbPath = Path.Combine(
|
private readonly DatabaseLocation location;
|
||||||
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
|
|
||||||
"duempelkas.db");
|
|
||||||
|
|
||||||
public async Task<string> CreateBackupAsync()
|
private readonly IFileDialogService dialogs;
|
||||||
|
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
|
||||||
{
|
{
|
||||||
if (!File.Exists(DbPath))
|
this.location = location;
|
||||||
{
|
this.dialogs = dialogs ?? new FileDialogService();
|
||||||
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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
// Ensure no pooled connection keeps stale handles to the previous DB file.
|
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
|
||||||
SqliteConnection.ClearAllPools();
|
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||||
|
await CreateBackupToAsync(path);
|
||||||
var targetDirectory = Path.GetDirectoryName(DbPath);
|
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
|
||||||
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}";
|
|
||||||
}
|
}
|
||||||
|
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);
|
Snapshot(location.Path, staged);
|
||||||
return Path.Combine(documentsPath, suggestedFileName);
|
File.Move(staged, path, overwrite: true);
|
||||||
}
|
}
|
||||||
|
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? RunPowerShellDialog(string script)
|
private void RejectActivePath(string path)
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo
|
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
|
||||||
{
|
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
|
||||||
FileName = "powershell",
|
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
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)
|
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)
|
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var query = db.Entries.Where(e => e.AccountId == accountId);
|
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)
|
public async Task<List<int>> GetEntryYearsAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
return await db.Entries
|
return await db.Entries
|
||||||
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<List<int>> GetAllEntryYearsAsync()
|
public async Task<List<int>> GetAllEntryYearsAsync()
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
return await db.Entries
|
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)
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||||
@@ -100,7 +112,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = displayId,
|
DisplayId = displayId,
|
||||||
Type = type,
|
Type = type,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
db.Entries.Add(entry);
|
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);
|
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)
|
if (sourceAccountId == targetAccountId)
|
||||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
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 db = await _dbFactory.CreateDbContextAsync();
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -125,7 +139,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = transferDisplayId,
|
DisplayId = transferDisplayId,
|
||||||
Type = EntryType.Expense,
|
Type = EntryType.Expense,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +152,7 @@ public class EntryService : IEntryService
|
|||||||
DisplayId = transferDisplayId,
|
DisplayId = transferDisplayId,
|
||||||
Type = EntryType.Income,
|
Type = EntryType.Income,
|
||||||
Date = date,
|
Date = date,
|
||||||
Title = title,
|
Title = title.Trim(),
|
||||||
Amount = amount
|
Amount = amount
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,10 +173,12 @@ public class EntryService : IEntryService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
return sourceEntry.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteEntryAsync(int entryId)
|
public async Task DeleteEntryAsync(int entryId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -185,6 +201,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task RestoreEntryAsync(int entryId)
|
public async Task RestoreEntryAsync(int entryId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
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)
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -216,7 +235,7 @@ public class EntryService : IEntryService
|
|||||||
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
|
||||||
|
|
||||||
entry.Date = date;
|
entry.Date = date;
|
||||||
entry.Title = title;
|
entry.Title = title.Trim();
|
||||||
entry.Amount = amount;
|
entry.Amount = amount;
|
||||||
|
|
||||||
if (link != null)
|
if (link != null)
|
||||||
@@ -226,7 +245,7 @@ public class EntryService : IEntryService
|
|||||||
if (otherEntry != null)
|
if (otherEntry != null)
|
||||||
{
|
{
|
||||||
otherEntry.Date = date;
|
otherEntry.Date = date;
|
||||||
otherEntry.Title = title;
|
otherEntry.Title = title.Trim();
|
||||||
otherEntry.Amount = amount;
|
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)
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -249,12 +270,17 @@ public class EntryService : IEntryService
|
|||||||
var otherEntry = await db.Entries.FindAsync(otherEntryId)
|
var otherEntry = await db.Entries.FindAsync(otherEntryId)
|
||||||
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
|
?? 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
|
// Update date, title, amount on both sides
|
||||||
entry.Date = date;
|
entry.Date = date;
|
||||||
entry.Title = title;
|
entry.Title = title.Trim();
|
||||||
entry.Amount = amount;
|
entry.Amount = amount;
|
||||||
otherEntry.Date = date;
|
otherEntry.Date = date;
|
||||||
otherEntry.Title = title;
|
otherEntry.Title = title.Trim();
|
||||||
otherEntry.Amount = amount;
|
otherEntry.Amount = amount;
|
||||||
|
|
||||||
// If the linked account has changed, move the other entry to the new account
|
// 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.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Duempelkas.App.Services;
|
using Duempelkas.App.Services;
|
||||||
|
|
||||||
namespace Duempelkas.Infrastructure.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);
|
string? staged = null;
|
||||||
if (filePath == null) return null;
|
try
|
||||||
|
|
||||||
await File.WriteAllBytesAsync(filePath, content);
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task OpenFileAsync(string filePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||||
return Task.Run(() =>
|
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
|
||||||
{
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
|
||||||
{
|
if (folder ? !Directory.Exists(path) : !File.Exists(path))
|
||||||
// Fallback: save to Documents folder
|
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
|
||||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
var start = new ProcessStartInfo { UseShellExecute = true };
|
||||||
return Path.Combine(documentsPath, suggestedFileName);
|
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(clubName).Bold().FontSize(20);
|
||||||
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1);
|
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(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);
|
col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||||
});
|
});
|
||||||
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries.Where(e => !e.IsDeleted))
|
||||||
{
|
{
|
||||||
var titleText = entry.Title;
|
var titleText = entry.Title;
|
||||||
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
||||||
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
|
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 ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
|
||||||
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
||||||
summaryCol.Item().PaddingTop(5).Row(row =>
|
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)
|
row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
|
||||||
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
.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 ({year.Value})"
|
||||||
: "Übersicht aller Konten";
|
: "Übersicht aller Konten";
|
||||||
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
|
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)}")
|
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
|
||||||
.FontSize(10)
|
.FontSize(10)
|
||||||
.SemiBold()
|
.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, 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)
|
foreach (var account in accounts)
|
||||||
{
|
{
|
||||||
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
|
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ namespace Duempelkas.Infrastructure.Services;
|
|||||||
|
|
||||||
public class SettingsService : ISettingsService
|
public class SettingsService : ISettingsService
|
||||||
{
|
{
|
||||||
private static readonly string SettingsPath = Path.Combine(
|
private readonly string SettingsPath;
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"Duempelkas", "settings.json");
|
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 };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
|
public async Task<bool> GetShowDeletedEntriesAsync() => (await LoadAsync()).ShowDeletedEntries;
|
||||||
|
|
||||||
|
public async Task SetShowDeletedEntriesAsync(bool show)
|
||||||
|
{
|
||||||
|
var settings = await LoadAsync();
|
||||||
|
settings.ShowDeletedEntries = show;
|
||||||
|
await SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetClubNameAsync()
|
public async Task<string?> GetClubNameAsync()
|
||||||
{
|
{
|
||||||
var settings = await LoadAsync();
|
var settings = await LoadAsync();
|
||||||
@@ -24,7 +34,15 @@ public class SettingsService : ISettingsService
|
|||||||
await SaveAsync(settings);
|
await SaveAsync(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<AppSettings> LoadAsync()
|
public async Task<int> GetSetupStepAsync() => (await LoadAsync()).SetupStep;
|
||||||
|
public async Task SetSetupStepAsync(int step)
|
||||||
|
{
|
||||||
|
var settings = await LoadAsync();
|
||||||
|
settings.SetupStep = Math.Clamp(step, 0, 4);
|
||||||
|
await SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AppSettings> LoadAsync()
|
||||||
{
|
{
|
||||||
if (!File.Exists(SettingsPath))
|
if (!File.Exists(SettingsPath))
|
||||||
return new AppSettings();
|
return new AppSettings();
|
||||||
@@ -40,7 +58,7 @@ public class SettingsService : ISettingsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SaveAsync(AppSettings settings)
|
private async Task SaveAsync(AppSettings settings)
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(SettingsPath)!;
|
var dir = Path.GetDirectoryName(SettingsPath)!;
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
@@ -50,6 +68,8 @@ public class SettingsService : ISettingsService
|
|||||||
|
|
||||||
private class AppSettings
|
private class AppSettings
|
||||||
{
|
{
|
||||||
|
public bool ShowDeletedEntries { get; set; }
|
||||||
public string? ClubName { get; set; }
|
public string? ClubName { get; set; }
|
||||||
|
public int SetupStep { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class BookingDateInputTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DateIsValidatedOnLeavingField_WithoutInterruptingNativeCalendar()
|
||||||
|
{
|
||||||
|
using var context = new BunitContext();
|
||||||
|
var value = "2025-12-31";
|
||||||
|
var field = context.Render<BookingDateInput>(p => p
|
||||||
|
.Add(x => x.Value, value).Add(x => x.ValueChanged, next => value = next));
|
||||||
|
field.Find("input").Input("2026-01-01");
|
||||||
|
Assert.Equal("2025-12-31", value);
|
||||||
|
field.Find("input").Blur();
|
||||||
|
Assert.Equal("2026-01-01", value);
|
||||||
|
field.Render(p => p.Add(x => x.Value, value));
|
||||||
|
// The browser owns the edited value; avoid resetting its calendar by writing it back.
|
||||||
|
Assert.Equal("2025-12-31", field.Find("input").GetAttribute("value"));
|
||||||
|
field.Render(p => p.Add(x => x.Value, "2027-02-03"));
|
||||||
|
Assert.Equal("2027-02-03", field.Find("input").GetAttribute("value"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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 async Task PendingDate_IsProtectedOnCancel_AndValidatedBeforeSubmit()
|
||||||
|
{
|
||||||
|
using var f = new LedgerUiFixture();
|
||||||
|
var dialog = f.Context.Render<AddEntryDialog>(p => p.Add(x => x.AccountId, 1).Add(x => x.ContextYear, DateTime.Today.Year));
|
||||||
|
dialog.Find("#entry-date").Input($"{DateTime.Today.Year + 1}-01-02");
|
||||||
|
Assert.Empty(dialog.FindAll(".booking-year-warning"));
|
||||||
|
var modal = dialog.FindComponent<ModalDialog>();
|
||||||
|
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||||
|
Assert.NotEmpty(dialog.FindAll("[role=alertdialog]"));
|
||||||
|
dialog.Find("[data-discard-continue]").Click();
|
||||||
|
dialog.Find("#entry-title").Input("Pending date");
|
||||||
|
dialog.Find("#entry-amount").Input("12,50");
|
||||||
|
// A keyboard submit can happen without the date field losing focus first.
|
||||||
|
dialog.Find("form").Submit();
|
||||||
|
Assert.NotEmpty(dialog.FindAll(".booking-year-warning"));
|
||||||
|
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||||
|
Assert.Empty(await f.Service<IEntryService>().GetEntriesAsync(1, (int?)null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("");
|
||||||
|
dialog.Find("#entry-date").Blur();
|
||||||
|
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");
|
||||||
|
dialog.Find("#entry-date").Blur();
|
||||||
|
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,50 @@
|
|||||||
|
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));
|
||||||
|
Assert.Equal("110,00", dialog.Find("#carryover").GetAttribute("value"));
|
||||||
|
dialog.Find("#carryover").Input("120,00");
|
||||||
|
Assert.Equal("120,00", dialog.Find("#carryover").GetAttribute("value"));
|
||||||
|
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");
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_db.Database.CloseConnection();
|
_db.Database.CloseConnection();
|
||||||
@@ -118,6 +174,8 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
|||||||
|
|
||||||
private sealed class FixedSettingsService : ISettingsService
|
private sealed class FixedSettingsService : ISettingsService
|
||||||
{
|
{
|
||||||
|
public Task<bool> GetShowDeletedEntriesAsync() => Task.FromResult(false);
|
||||||
|
public Task SetShowDeletedEntriesAsync(bool show) => Task.CompletedTask;
|
||||||
private readonly string? _clubName;
|
private readonly string? _clubName;
|
||||||
|
|
||||||
public FixedSettingsService(string? clubName)
|
public FixedSettingsService(string? clubName)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class DesktopResourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StartupStylesAndFonts_AreDeliveredLocally()
|
||||||
|
{
|
||||||
|
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
|
||||||
|
Assert.NotNull(root);
|
||||||
|
var web = Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "wwwroot");
|
||||||
|
var html = File.ReadAllText(Path.Combine(web, "index.html"));
|
||||||
|
Assert.DoesNotContain("https://", html);
|
||||||
|
foreach (Match match in Regex.Matches(html, "(?:href|src)=\"([^\"]+)\""))
|
||||||
|
{
|
||||||
|
var path = match.Groups[1].Value;
|
||||||
|
if (path.Length == 0 || path == "/" || path.StartsWith("_framework")) continue;
|
||||||
|
Assert.True(File.Exists(Path.Combine(web, path)), path);
|
||||||
|
}
|
||||||
|
var icons = Path.Combine(web, "vendor", "bootstrap-icons");
|
||||||
|
var css = File.ReadAllText(Path.Combine(icons, "bootstrap-icons.min.css"));
|
||||||
|
foreach (Match match in Regex.Matches(css, "url\\([\"']?([^\"')]+)"))
|
||||||
|
Assert.True(File.Exists(Path.Combine(icons, match.Groups[1].Value.Split('?')[0])));
|
||||||
|
Assert.True(File.Exists(Path.Combine(icons, "LICENSE")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="bunit" Version="2.13.3" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
<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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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-buttons");
|
||||||
|
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")));
|
||||||
|
page.WaitForAssertion(() => Assert.Empty(page.FindAll(".deleted-status")));
|
||||||
|
Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 EmptyAccount_ShowsExplanationRatherThanParameterName()
|
||||||
|
{
|
||||||
|
using var f = new LedgerUiFixture();
|
||||||
|
f.Service<Microsoft.AspNetCore.Components.NavigationManager>().NavigateTo("/accounts/2");
|
||||||
|
var page = f.Context.Render<Duempelkas.App.Components.Routes>();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Noch keine Buchungen vorhanden.", page.Markup));
|
||||||
|
Assert.DoesNotContain("emptyEntriesMessage", page.Markup);
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void Actions_AreDirectIconButtonsWithTitlesAndBookingSpecificNames()
|
||||||
|
{
|
||||||
|
using var fixture = new LedgerUiFixture();
|
||||||
|
var context = fixture.Context;
|
||||||
|
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.Empty(table.FindAll("summary"));
|
||||||
|
var edit = table.Find("button[title='Buchung bearbeiten']");
|
||||||
|
Assert.Contains("2026-001 Porto", edit.GetAttribute("aria-label"));
|
||||||
|
Assert.NotNull(edit.QuerySelector(".bi-pencil[aria-hidden=true]"));
|
||||||
|
Assert.NotNull(table.Find("button.btn-outline-danger .bi-trash"));
|
||||||
|
Assert.Contains("Porto", edit.GetAttribute("aria-label"));
|
||||||
|
edit.Click();
|
||||||
|
Assert.Equal(1, edited);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components.Accounts;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class EntrySearchTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Search_FindsKnownBookingInLargeList_AndResetsWithoutChangingAmounts()
|
||||||
|
{
|
||||||
|
using var fixture = new LedgerUiFixture();
|
||||||
|
var context = fixture.Context;
|
||||||
|
var entries = Enumerable.Range(1, 400).Select(i => new EntryDto(i, 1, $"2026-{i:D3}", EntryType.Income,
|
||||||
|
new(2026, 1, 1), i == 321 ? "Älteres Jubiläum" : $"Beitrag {i}", i == 321 ? 12.50m : i, false, false, null, null, null)).ToList();
|
||||||
|
var original = entries.Sum(e => e.Amount);
|
||||||
|
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, entries));
|
||||||
|
table.Find("#entry-search").Input("Älteres 12,50");
|
||||||
|
Assert.Single(table.FindAll("tbody tr"));
|
||||||
|
Assert.Contains("12,50", table.Find(".search-summary").TextContent);
|
||||||
|
Assert.Contains("unverändert", table.Find(".search-summary").TextContent);
|
||||||
|
table.Find("#entry-search").Input("nicht vorhanden");
|
||||||
|
Assert.Empty(table.FindAll("tbody tr"));
|
||||||
|
Assert.Contains("Keine Treffer", table.Markup);
|
||||||
|
table.Find(".filter-reset").Click();
|
||||||
|
Assert.Equal(400, table.FindAll("tbody tr").Count);
|
||||||
|
Assert.Equal(original, entries.Sum(e => e.Amount));
|
||||||
|
table.Find("#entry-search").Input("2026-321");
|
||||||
|
Assert.Single(table.FindAll("tbody tr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sort_UsesSignedAmounts_AndDeletedHistoryDoesNotAffectResultSum()
|
||||||
|
{
|
||||||
|
var entries = new List<EntryDto> {
|
||||||
|
new(1,1,"2026-001",EntryType.Expense,DateTime.Today,"Porto",20m,false,false,null,null,null),
|
||||||
|
new(2,1,"2026-002",EntryType.Income,DateTime.Today,"Beitrag",10m,false,false,null,null,null),
|
||||||
|
new(3,1,"2026-003",EntryType.Income,DateTime.Today,"Gelöscht",99m,true,false,null,null,null) };
|
||||||
|
Assert.Equal(new[] { 2, 1 }, EntryListQuery.Apply(entries, "", false, "amount-desc").Select(e => e.Id));
|
||||||
|
Assert.Single(EntryListQuery.Apply(entries, "-20,00", false, "date-asc"));
|
||||||
|
Assert.Equal(3, EntryListQuery.Apply(entries, "", true, "date-asc").Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Rendering;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class ErrorRecoveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnexpectedError_OffersRecoveryWithoutLeakingBookingData()
|
||||||
|
{
|
||||||
|
using var context = new BunitContext();
|
||||||
|
context.Services.AddSingleton<ToastService>();
|
||||||
|
var boundary = context.Render<AppErrorBoundary>(p => p.AddChildContent<Throws>());
|
||||||
|
boundary.WaitForAssertion(() => Assert.Contains("nicht bestätigt", boundary.Markup));
|
||||||
|
Assert.Contains("Zur Kontenübersicht", boundary.Markup);
|
||||||
|
Assert.Contains("InvalidOperationException", boundary.Markup);
|
||||||
|
Assert.DoesNotContain("SECRET_BOOKING_DATA", boundary.Markup);
|
||||||
|
}
|
||||||
|
private sealed class Throws : ComponentBase
|
||||||
|
{
|
||||||
|
protected override void BuildRenderTree(RenderTreeBuilder builder) => throw new InvalidOperationException("SECRET_BOOKING_DATA");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,36 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class GettingStartedTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Introduction_CanBeSkippedAndResumed_AndCreatesNoSampleBookings()
|
||||||
|
{
|
||||||
|
using var f = new LedgerUiFixture(false);
|
||||||
|
var navigation = f.Service<NavigationManager>();
|
||||||
|
navigation.NavigateTo("/einstieg");
|
||||||
|
var page = f.Context.Render<Routes>();
|
||||||
|
page.WaitForElement("#setup-club").Input("Mein Testverein");
|
||||||
|
page.Find(".setup-next").Click();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Contains("später fortsetzen")).Click();
|
||||||
|
navigation.NavigateTo("/einstieg");
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Trim() == "Konto anlegen").Click();
|
||||||
|
page.Find("#account-name").Input("Barkasse");
|
||||||
|
page.Find("#account-initial").Input("25,00");
|
||||||
|
page.Find("dialog form").Submit();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Die erste Buchung", page.Markup));
|
||||||
|
var account = Assert.Single(await f.Service<IAccountService>().GetAllAccountsAsync());
|
||||||
|
Assert.Equal(25m, account.TotalBalance);
|
||||||
|
Assert.Empty(await f.Service<IEntryService>().GetEntriesAsync(account.Id, (int?)null));
|
||||||
|
Assert.Equal(2, await f.Service<ISettingsService>().GetSetupStepAsync());
|
||||||
|
navigation.NavigateTo("/hilfe");
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("auch ohne Internet", page.Markup));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components.Accounts;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Duempelkas.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class HistoryPreferenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PreferenceSurvivesRestart_AndPreservesOtherSettings()
|
||||||
|
{
|
||||||
|
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-history-" + Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = new SettingsService(folder);
|
||||||
|
Assert.False(await settings.GetShowDeletedEntriesAsync());
|
||||||
|
await settings.SetClubNameAsync("Testverein");
|
||||||
|
await settings.SetShowDeletedEntriesAsync(true);
|
||||||
|
var restarted = new SettingsService(folder);
|
||||||
|
Assert.True(await restarted.GetShowDeletedEntriesAsync());
|
||||||
|
Assert.Equal("Testverein", await restarted.GetClubNameAsync());
|
||||||
|
await restarted.SetSetupStepAsync(2);
|
||||||
|
Assert.True(await new SettingsService(folder).GetShowDeletedEntriesAsync());
|
||||||
|
await restarted.SetShowDeletedEntriesAsync(false);
|
||||||
|
Assert.False(await new SettingsService(folder).GetShowDeletedEntriesAsync());
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(folder)) Directory.Delete(folder, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoryStaysEnabledAcrossAccountsAndFilterReset_UntilSwitchedOff()
|
||||||
|
{
|
||||||
|
using var f = new LedgerUiFixture();
|
||||||
|
List<EntryDto> Rows(int account) => [new(account, account, "2026-001", EntryType.Income,
|
||||||
|
DateTime.Today, "Deleted test", 10m, true, false, null, null, null)];
|
||||||
|
var first = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(1)));
|
||||||
|
Assert.Empty(first.FindAll("tbody tr"));
|
||||||
|
await first.Find("#show-deleted").ChangeAsync(new() { Value = true });
|
||||||
|
Assert.True(await f.Service<ISettingsService>().GetShowDeletedEntriesAsync());
|
||||||
|
first.Find(".filter-reset").Click();
|
||||||
|
Assert.Single(first.FindAll("tbody tr"));
|
||||||
|
var second = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(2)));
|
||||||
|
second.WaitForAssertion(() => Assert.Single(second.FindAll("tbody tr")));
|
||||||
|
await second.Find("#show-deleted").ChangeAsync(new() { Value = false });
|
||||||
|
Assert.False(await f.Service<ISettingsService>().GetShowDeletedEntriesAsync());
|
||||||
|
var returning = f.Context.Render<EntryTable>(p => p.Add(x => x.Entries, Rows(1)));
|
||||||
|
returning.WaitForAssertion(() => Assert.Empty(returning.FindAll("tbody tr")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class LayoutControlTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TextScaleControl_ChangesPresentationWithoutChangingLedger()
|
||||||
|
{
|
||||||
|
using var f = new LedgerUiFixture();
|
||||||
|
var entries = f.Service<IEntryService>();
|
||||||
|
await entries.CreateEntryAsync(1, EntryType.Expense, DateTime.Today, "Porto mit sehr langer Beschreibung und Umlauten äöü", 4.20m);
|
||||||
|
f.Service<NavigationManager>().NavigateTo("/accounts/1");
|
||||||
|
var page = f.Context.Render<Routes>();
|
||||||
|
page.WaitForElement("#text-scale").Change("200");
|
||||||
|
Assert.Contains(f.Context.JSInterop.Invocations, i => i.Identifier == "duempelkasReading.set" && Equals(i.Arguments[0], 200));
|
||||||
|
Assert.Equal(95.80m, (await f.Service<IBalanceQueryService>().GetAccountBalanceAsync(1)).TotalBalance);
|
||||||
|
Capture("account", page.Markup);
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe")).Click();
|
||||||
|
page.WaitForElement("#entry-title");
|
||||||
|
Capture("booking", page.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Capture(string name, string markup)
|
||||||
|
{
|
||||||
|
var output = Environment.GetEnvironmentVariable("DUEMPELKAS_LAYOUT_EVIDENCE");
|
||||||
|
if (output == null) return;
|
||||||
|
Directory.CreateDirectory(output);
|
||||||
|
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent;
|
||||||
|
var web = new Uri(Path.Combine(root!.FullName, "src", "Duempelkas.Desktop", "wwwroot") + Path.DirectorySeparatorChar).AbsoluteUri;
|
||||||
|
File.WriteAllText(Path.Combine(output, name + ".html"), "<!doctype html><html lang='de'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>" +
|
||||||
|
$"<link rel='stylesheet' href='{web}vendor/bootstrap/bootstrap.min.css'><link rel='stylesheet' href='{web}vendor/bootstrap-icons/bootstrap-icons.min.css'><link rel='stylesheet' href='{web}css/app.css'>" +
|
||||||
|
"</head><body>" + markup + $"<script src='{web}js/app.js'></script>" +
|
||||||
|
"<script>document.querySelectorAll('dialog').forEach(d=>d.showModal());</script></body></html>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,47 @@
|
|||||||
|
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);
|
||||||
|
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||||
|
Assert.Empty(modal.FindAll("[role=alertdialog]"));
|
||||||
|
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
|
||||||
|
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.Empty(control.FindAll("[role=status]"));
|
||||||
|
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,67 @@
|
|||||||
|
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.WaitForElement(".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.WaitForElement(".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("input[type=date]").Blur();
|
||||||
|
dialog.Find("#entry-title").Input("Außerhalb");
|
||||||
|
dialog.Find("#entry-amount").Input("12.50");
|
||||||
|
Assert.Contains("außerhalb", dialog.Markup);
|
||||||
|
Assert.NotNull(dialog.Find(".booking-year-warning[role=status] .bi-exclamation-triangle-fill"));
|
||||||
|
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.WaitForElement(".account-card").Click();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||||
|
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user