Compare commits

..
2 Commits
Author SHA1 Message Date
troogs 8c52a46f58 refactor: update project references and remove obsolete solution file 2026-11-02 21:56:36 +01:00
troogs e6cd5f5a1d 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
2026-11-02 21:47:44 +01:00
34 changed files with 287 additions and 74 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"type": "process", "type": "process",
"args": [ "args": [
"build", "build",
"${workspaceFolder}/Duempelkas.slnx" "${workspaceFolder}/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj"
], ],
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"group": { "group": {
-11
View File
@@ -1,11 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Duempelkas.App/Duempelkas.App.csproj" />
<Project Path="src/Duempelkas.Desktop/Duempelkas.Desktop.csproj" />
<Project Path="src/Duempelkas.Domain/Duempelkas.Domain.csproj" />
<Project Path="src/Duempelkas.Infrastructure/Duempelkas.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Duempelkas.Tests/Duempelkas.Tests.csproj" />
</Folder>
</Solution>
+36
View File
@@ -107,3 +107,39 @@ Zusätzliche Headless-Chromium-Prüfung der tatsächlich gerenderten Konto-/Buch
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. 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. 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,9 +1,9 @@
@inject NavigationManager Navigation @inject NavigationManager Navigation
<a class="card account-card h-100" href="@($"/accounts/{Account.Id}")" @onclick="Navigate" @onclick:preventDefault="true"> <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>
@@ -26,15 +26,14 @@
} }
else else
{ {
<details class="entry-action-menu"> <div class="entry-action-buttons">
<summary aria-label="@($"Aktionen für {Entry.DisplayId} {Entry.Title}")">Aktionen</summary> <button type="button" class="btn btn-outline-secondary" title="Buchung bearbeiten" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")">
<button type="button" class="btn btn-outline-secondary" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")"> <i class="bi bi-pencil" aria-hidden="true"></i>
<i class="bi bi-pencil" aria-hidden="true"></i> Bearbeiten
</button> </button>
<button type="button" class="btn btn-outline-danger" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.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" aria-hidden="true"></i> Löschen <i class="bi bi-trash" aria-hidden="true"></i>
</button> </button>
</details> </div>
} }
</td> </td>
</tr> </tr>
@@ -1,3 +1,5 @@
@inject ISettingsService Settings
<div class="entry-filters"> <div class="entry-filters">
<div><label for="entry-search">Buchungen suchen</label> <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> <input id="entry-search" type="search" class="form-control" @bind="searchText" @bind:event="oninput" placeholder="Beschreibung, Nummer, Datum oder Betrag" /></div>
@@ -16,11 +18,14 @@
} }
@if (Entries.Any(e => e.IsDeleted)) @if (Entries.Any(e => e.IsDeleted))
{ {
<label class="history-toggle"><input type="checkbox" @bind="showDeleted" /> Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label> <div class="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"> <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>
@@ -6,11 +6,13 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable public partial class EntryTable
{ {
private bool showDeleted; 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 searchText = string.Empty;
private string sort = "date-asc"; private string sort = "date-asc";
private List<EntryDto> VisibleEntries => Duempelkas.App.Services.EntryListQuery.Apply(Entries, searchText, showDeleted, sort); 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 decimal ResultSum => VisibleEntries.Where(e => !e.IsDeleted).Sum(Duempelkas.App.Services.EntryListQuery.SignedAmount);
private void ResetFilters() { searchText = string.Empty; sort = "date-asc"; showDeleted = false; } private void ResetFilters() { searchText = string.Empty; sort = "date-asc"; }
[Parameter] public int? HighlightedEntryId { get; set; } [Parameter] public int? HighlightedEntryId { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden."; [Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters #region Parameters
@@ -1,8 +1,6 @@
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard <input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()" data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled" @bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled" />
aria-describedby="@(Id + "-rejected")" />
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
@code { @code {
[Parameter] public string Id { get; set; } = "amount"; [Parameter] public string Id { get; set; } = "amount";
@@ -10,15 +8,12 @@
[Parameter] public EventCallback<string> ValueChanged { get; set; } [Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowNegative { get; set; } [Parameter] public bool AllowNegative { get; set; }
private string? rejection;
private async Task SetValue(string value) private async Task SetValue(string value)
{ {
if (!BookingInput.IsAmountDraft(value, AllowNegative)) if (!BookingInput.IsAmountDraft(value, AllowNegative))
{ {
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
return; return;
} }
rejection = null;
await ValueChanged.InvokeAsync(value); await ValueChanged.InvokeAsync(value);
} }
} }
@@ -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,7 +1,7 @@
@inject ToastService Toasts @inject ToastService Toasts
@inject IEntryService EntryService @inject IEntryService EntryService
<ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)"> <ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && (IsDirty || pendingDateChanges))">
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5> <h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")"> <form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
@@ -24,7 +24,7 @@
<label class="form-label" for="entry-date">Datum</label> <label class="form-label" for="entry-date">Datum</label>
<div> <div>
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" /> <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> } @if (date == default) { <p id="entry-date-help" class="text-danger">Bitte ein gültiges Datum eingeben.</p> }
</div> </div>
@@ -43,10 +43,13 @@
@if (OutsideYear && date != default) @if (OutsideYear && date != default)
{ {
<div class="alert alert-warning" role="status"> <div class="alert alert-warning booking-year-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. <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> <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>
} }
</fieldset> </fieldset>
@@ -59,4 +62,4 @@
</form> </form>
</ModalDialog> </ModalDialog>
@code { private ModalDialog? modal; } @code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
@@ -76,6 +76,8 @@ public partial class AddEntryDialog
private async Task Save() private async Task Save()
{ {
if (isSaving || completed) return;
if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync();
if (isSaving || completed || (committedResult == null && !CanSave)) return; if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true); var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true; isSaving = true;
@@ -2,7 +2,7 @@
@inject IEntryService EntryService @inject IEntryService EntryService
@inject IAccountService AccountService @inject IAccountService AccountService
<ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)"> <ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && (IsDirty || pendingDateChanges))">
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5> <h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p> <p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
@@ -30,7 +30,7 @@
</select> </select>
<label class="form-label" for="transfer-date">Datum</label> <label class="form-label" for="transfer-date">Datum</label>
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" /> <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> <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" /> <input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
@@ -40,12 +40,15 @@
<p class="text-muted">Betrag ohne Minuszeichen.</p></div> <p class="text-muted">Betrag ohne Minuszeichen.</p></div>
</div> </div>
@if (OutsideYear) @if (OutsideYear && date != default)
{ {
<div class="alert alert-warning" role="status"> <div class="alert alert-warning booking-year-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. <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> <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>
} }
</fieldset> </fieldset>
@@ -58,4 +61,4 @@
</form> </form>
</ModalDialog> </ModalDialog>
@code { private ModalDialog? modal; } @code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
@@ -91,6 +91,8 @@ public partial class AddTransferDialog
private async Task Save() private async Task Save()
{ {
if (isSaving || completed) return;
if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync();
if (isSaving || completed || (committedResult == null && !CanSave)) return; if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true); var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true; isSaving = true;
@@ -13,7 +13,7 @@
<a class="btn btn-outline-secondary" href="hilfe">Hilfe</a> <a class="btn btn-outline-secondary" href="hilfe">Hilfe</a>
<label for="text-scale">Textgröße</label> <label for="text-scale">Textgröße</label>
<select id="text-scale" @bind="fontScale" @bind:after="ApplyTextScale"> <select id="text-scale" @bind="fontScale" @bind:after="ApplyTextScale">
<option value="100">Normal</option><option value="125">Groß (125 %)</option> <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> <option value="150">Sehr groß (150 %)</option><option value="200">200 %</option>
</select> </select>
</div> </div>
@@ -32,7 +32,8 @@
{ {
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog"> <ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
<h5>Über Dümpelkas &middot; Version @AppVersion</h5> <h5>Über Dümpelkas &middot; 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">
@@ -11,7 +11,7 @@ public partial class MainLayout
if (firstRender) if (firstRender)
{ {
var value = await JS.InvokeAsync<int>("duempelkasReading.get"); var value = await JS.InvokeAsync<int>("duempelkasReading.get");
fontScale = value is 125 or 150 or 200 ? value : 100; fontScale = value is 75 or 125 or 150 or 200 ? value : 100;
StateHasChanged(); StateHasChanged();
} }
} }
@@ -24,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
@@ -258,7 +258,7 @@ public partial class AccountDetail
private async Task HandleExport() private async Task HandleExport()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Kontoauszug wird als PDF erstellt und gespeichert … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
@@ -277,7 +277,7 @@ public partial class AccountDetail
private async Task HandleOpenSavedPdf() private async Task HandleOpenSavedPdf()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Gespeicherte PDF-Datei wird geöffnet … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
+4 -4
View File
@@ -101,7 +101,7 @@ public partial class Dashboard
private async Task HandleBackupAsync() private async Task HandleBackupAsync()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Sicherung wird erstellt … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
@@ -127,7 +127,7 @@ public partial class Dashboard
private async Task HandleConfirmRestore() private async Task HandleConfirmRestore()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Sicherung wird wiederhergestellt … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
@@ -144,7 +144,7 @@ public partial class Dashboard
private async Task HandleDashboardExportAsync() private async Task HandleDashboardExportAsync()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Kontenübersicht wird als PDF erstellt und gespeichert … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
@@ -180,7 +180,7 @@ public partial class Dashboard
private async Task HandleOpenSavedPdf() private async Task HandleOpenSavedPdf()
{ {
if (fileBusy) return; if (fileBusy) return;
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); var busyToast = Toasts.Show("Gespeicherte PDF-Datei wird geöffnet … Bitte warten.", persistent: true);
fileBusy = true; fileBusy = true;
fileResult = null; fileResult = null;
try try
@@ -2,6 +2,8 @@ namespace Duempelkas.App.Services;
public interface ISettingsService public interface ISettingsService
{ {
Task<bool> GetShowDeletedEntriesAsync();
Task SetShowDeletedEntriesAsync(bool show);
Task<int> GetSetupStepAsync() => Task.FromResult(0); Task<int> GetSetupStepAsync() => Task.FromResult(0);
Task SetSetupStepAsync(int step) => Task.CompletedTask; Task SetSetupStepAsync(int step) => Task.CompletedTask;
Task<string?> GetClubNameAsync(); Task<string?> GetClubNameAsync();
@@ -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>
+3
View File
@@ -66,6 +66,9 @@ class Program
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.
+13 -6
View File
@@ -350,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;
@@ -629,7 +630,6 @@ fieldset { min-width: 0; }
.toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.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; } } @keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } } @media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
.input-rejection:empty { display: none; }
.app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; } .app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; }
.app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); } .app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); }
@@ -642,9 +642,11 @@ 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; } .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
.entry-actions button, .entry-action-menu summary { min-height: 44px; padding: .6rem; } .entry-actions button { min-height: 44px; min-width: 44px; padding: .6rem; }
.entry-action-menu summary { cursor: pointer; border: 1px solid var(--color-border); border-radius: .4rem; } .entry-action-buttons { display: flex; gap: .5rem; }
.entry-action-menu button { display: block; width: 100%; margin-top: .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; } .entry-description { overflow-wrap: anywhere; }
@media (max-width: 900px) { @media (max-width: 900px) {
.entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } .entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
@@ -655,7 +657,12 @@ body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
.entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; } .entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; }
} }
.history-toggle { display: block; padding: .75rem 0; } .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; } .entry-deleted { opacity: 1 !important; }
.deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; } .deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; }
+2 -5
View File
@@ -5,10 +5,7 @@
} }
function accept(input, text) { function accept(input, text) {
const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd); const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd);
const ok = allowed(input, next); return allowed(input, next);
const hint = document.getElementById(input.id + '-rejected');
if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.';
return ok;
} }
document.addEventListener('beforeinput', e => { document.addEventListener('beforeinput', e => {
if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault(); if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault();
@@ -34,7 +31,7 @@ window.duempelkasDialog = {
window.duempelkasReading = { window.duempelkasReading = {
get() { let value = 100; try { value = Number(localStorage.getItem('duempelkas-text-scale')) || 100; } catch (_) {} return this.set(value, false); }, get() { let value = 100; try { value = Number(localStorage.getItem('duempelkas-text-scale')) || 100; } catch (_) {} return this.set(value, false); },
set(value, persist = true) { set(value, persist = true) {
const scale = [100,125,150,200].includes(Number(value)) ? Number(value) : 100; const scale = [75,100,125,150,200].includes(Number(value)) ? Number(value) : 100;
document.documentElement.style.setProperty('--app-font-size', (16 * scale / 100) + 'px'); document.documentElement.style.setProperty('--app-font-size', (16 * scale / 100) + 'px');
if (persist) { try { localStorage.setItem('duempelkas-text-scale', String(scale)); } catch (_) {} } if (persist) { try { localStorage.setItem('duempelkas-text-scale', String(scale)); } catch (_) {} }
return scale; return scale;
@@ -12,6 +12,15 @@ public class SettingsService : ISettingsService
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();
@@ -59,6 +68,7 @@ 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; } public int SetupStep { get; set; }
} }
@@ -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"));
}
}
@@ -11,6 +11,26 @@ namespace Duempelkas.Tests;
public class BookingFormTests 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] [Fact]
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput() public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{ {
@@ -31,9 +51,11 @@ public class BookingFormTests
dialog.Find("#entry-amount").Input("12,50"); dialog.Find("#entry-amount").Input("12,50");
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-date").Input(""); dialog.Find("#entry-date").Input("");
dialog.Find("#entry-date").Blur();
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup); Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
dialog.Find("#entry-date").Input("2026-01-02"); dialog.Find("#entry-date").Input("2026-01-02");
dialog.Find("#entry-date").Blur();
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
} }
@@ -174,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)
@@ -9,7 +9,7 @@ public class DesktopResourceTests
public void StartupStylesAndFonts_AreDeliveredLocally() public void StartupStylesAndFonts_AreDeliveredLocally()
{ {
var root = new DirectoryInfo(AppContext.BaseDirectory); var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent; while (root != null && !File.Exists(Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "Duempelkas.Desktop.csproj"))) root = root.Parent;
Assert.NotNull(root); Assert.NotNull(root);
var web = Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "wwwroot"); var web = Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "wwwroot");
var html = File.ReadAllText(Path.Combine(web, "index.html")); var html = File.ReadAllText(Path.Combine(web, "index.html"));
@@ -15,7 +15,7 @@ public class EntryLifecycleUiTests
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m); await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m);
var page = f.Context.Render<Routes>(); var page = f.Context.Render<Routes>();
page.WaitForElement(".account-card").Click(); page.WaitForElement(".account-card").Click();
page.WaitForElement(".entry-action-menu"); page.WaitForElement(".entry-action-buttons");
page.Find("button.btn-outline-danger").Click(); page.Find("button.btn-outline-danger").Click();
page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup)); page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup));
Assert.Contains("Barkasse", page.Find("dialog").TextContent); Assert.Contains("Barkasse", page.Find("dialog").TextContent);
@@ -18,14 +18,18 @@ public class EntryPresentationTests
Assert.DoesNotContain("emptyEntriesMessage", page.Markup); Assert.DoesNotContain("emptyEntriesMessage", page.Markup);
} }
[Fact] [Fact]
public void Actions_HaveVisibleTextAndBookingSpecificNames() public void Actions_AreDirectIconButtonsWithTitlesAndBookingSpecificNames()
{ {
using var context = new BunitContext(); 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 entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null);
var edited = 0; var edited = 0;
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id)); var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id));
Assert.Contains("2026-001 Porto", table.Find("summary").GetAttribute("aria-label")); Assert.Empty(table.FindAll("summary"));
var edit = table.FindAll("button").Single(b => b.TextContent.Contains("Bearbeiten")); 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")); Assert.Contains("Porto", edit.GetAttribute("aria-label"));
edit.Click(); edit.Click();
Assert.Equal(1, edited); Assert.Equal(1, edited);
+2 -1
View File
@@ -12,7 +12,8 @@ public class EntrySearchTests
[Fact] [Fact]
public void Search_FindsKnownBookingInLargeList_AndResetsWithoutChangingAmounts() public void Search_FindsKnownBookingInLargeList_AndResetsWithoutChangingAmounts()
{ {
using var context = new BunitContext(); 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, 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(); 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 original = entries.Sum(e => e.Amount);
@@ -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")));
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ public class LayoutControlTests
if (output == null) return; if (output == null) return;
Directory.CreateDirectory(output); Directory.CreateDirectory(output);
var root = new DirectoryInfo(AppContext.BaseDirectory); var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root != null && !File.Exists(Path.Combine(root.FullName, "Duempelkas.slnx"))) root = root.Parent; while (root != null && !File.Exists(Path.Combine(root.FullName, "src", "Duempelkas.Desktop", "Duempelkas.Desktop.csproj"))) root = root.Parent;
var web = new Uri(Path.Combine(root!.FullName, "src", "Duempelkas.Desktop", "wwwroot") + Path.DirectorySeparatorChar).AbsoluteUri; 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'>" + 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'>" + $"<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'>" +
+1 -1
View File
@@ -36,7 +36,7 @@ public class ToastTests
var control = context.Render<AmountInput>(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value)); 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"); control.Find("input").Input("12,501");
Assert.Equal("12,50", amount); Assert.Equal("12,50", amount);
Assert.Contains("nicht übernommen", control.Markup); Assert.Empty(control.FindAll("[role=status]"));
control.Find("input").Input("12.25"); control.Find("input").Input("12.25");
Assert.Equal("12.25", amount); Assert.Equal("12.25", amount);
} }
@@ -34,18 +34,20 @@ public class YearNavigationTests
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025; context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
var page = context.Render<Routes>(); var page = context.Render<Routes>();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup)); page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click(); page.WaitForElement(".account-card").Click();
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup)); page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click(); page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup)); page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click(); page.WaitForElement(".account-card").Click();
page.WaitForElement(".summary-section"); page.WaitForElement(".summary-section");
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click(); page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
var dialog = page.FindComponent<AddEntryDialog>(); var dialog = page.FindComponent<AddEntryDialog>();
dialog.Find("input[type=date]").Input("2026-01-02"); 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-title").Input("Außerhalb");
dialog.Find("#entry-amount").Input("12.50"); dialog.Find("#entry-amount").Input("12.50");
Assert.Contains("außerhalb", dialog.Markup); 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")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true); dialog.Find("input[type=checkbox]").Change(true);
dialog.Find("form").Submit(); dialog.Find("form").Submit();
@@ -58,7 +60,7 @@ public class YearNavigationTests
page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click(); page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click();
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click(); page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup)); page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup));
page.Find(".account-card").Click(); page.WaitForElement(".account-card").Click();
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup)); page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year); Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
} }