Compare commits
2
Commits
86a73b862a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c52a46f58 | ||
|
|
e6cd5f5a1d |
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Duempelkas.slnx"
|
||||
"${workspaceFolder}/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
|
||||
@@ -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>
|
||||
@@ -59,3 +59,87 @@ Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separ
|
||||
## 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
|
||||
|
||||
<div class="card account-card h-100" @onclick="Navigate">
|
||||
<div class="card-body d-flex flex-column justify-content-between">
|
||||
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
|
||||
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
|
||||
<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 pb-0 pt-1 px-2">
|
||||
<h4 class="card-title mb-3 mt-0">@Account.Name</h4>
|
||||
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-5">
|
||||
@($"{Account.TotalBalance:N2} €")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<tr class="@GetRowClass()">
|
||||
<td>@Entry.DisplayId</td>
|
||||
<td>@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||
<td>
|
||||
@Entry.Title
|
||||
<td data-label="Nummer">@Entry.DisplayId</td>
|
||||
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||
<td data-label="Beschreibung" class="entry-description">
|
||||
<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))
|
||||
{
|
||||
<small class="text-muted ms-1">
|
||||
@@ -10,24 +14,24 @@
|
||||
</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
||||
<td data-label="Betrag" class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
||||
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Aktionen" class="entry-actions">
|
||||
@if (Entry.IsDeleted)
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" title="Wiederherstellen">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
<button type="button" class="btn btn-outline-success" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} wiederherstellen")">
|
||||
<i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Wiederherstellen
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<div class="entry-action-buttons">
|
||||
<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" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" title="Löschen">
|
||||
<i class="bi bi-trash"></i>
|
||||
<button type="button" class="btn btn-outline-danger" 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>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
<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">
|
||||
<thead class="table-light">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px;">LfdNr</th>
|
||||
<th style="width: 120px;">Datum</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th style="width: 140px;" class="text-end">Betrag</th>
|
||||
<th style="width: 80px;"></th>
|
||||
<th style="width: 11rem;">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in Entries)
|
||||
@foreach (var entry in VisibleEntries)
|
||||
{
|
||||
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||
}
|
||||
@@ -18,8 +43,8 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!Entries.Any())
|
||||
@if (!VisibleEntries.Any())
|
||||
{
|
||||
<div class="text-center py-3 text-muted">@EmptyMessage</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,14 @@ namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
|
||||
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
|
||||
aria-describedby="@(Id + "-rejected")" />
|
||||
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled" />
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "amount";
|
||||
@@ -10,15 +8,12 @@
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNegative { get; set; }
|
||||
private string? rejection;
|
||||
private async Task SetValue(string value)
|
||||
{
|
||||
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||
{
|
||||
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
|
||||
return;
|
||||
}
|
||||
rejection = null;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
@inject ToastService Toasts
|
||||
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
|
||||
<h5 class="mb-3">Neues Konto</h5>
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||
<div class="form-container">
|
||||
<label class="form-label">Kontoname</label>
|
||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||
<label class="form-label" for="account-name">Kontoname</label>
|
||||
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||
placeholder="z.B. Girokonto" />
|
||||
</div>
|
||||
<label for="account-initial">Anfangsbestand (€)</label>
|
||||
<AmountInput Id="account-initial" @bind-Value="initialAmount" AllowNegative="true" Disabled="saving" />
|
||||
<p>Geld, das vor der ersten erfassten Buchung vorhanden ist. 0 ist möglich, ein Minus bei überzogenem Konto ebenfalls. Der Anfangsbestand zählt nicht als neue Einnahme.</p>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success" disabled="@(saving || string.IsNullOrWhiteSpace(name) || !ValidInitial)"><i class="bi bi-check-lg"></i> Erstellen</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> OnSave { get; set; }
|
||||
public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
|
||||
#region Fields
|
||||
|
||||
private string name = string.Empty;
|
||||
private string initialAmount = "0,00";
|
||||
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
|
||||
private bool saving;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -24,13 +27,16 @@ public partial class AddAccountDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
if (!saving && !string.IsNullOrWhiteSpace(name) && ValidInitial)
|
||||
{
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
saving = true;
|
||||
try { Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out var initial); await OnSave.InvokeAsync(new(name.Trim(), initial)); }
|
||||
catch (Exception ex) { Toasts.Show("Das Konto konnte nicht angelegt werden. Die Eingaben bleiben erhalten.", Duempelkas.App.Services.ToastKind.Error, ex.Message); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
|
||||
<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>
|
||||
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
@@ -25,13 +24,13 @@
|
||||
|
||||
<label class="form-label" for="entry-date">Datum</label>
|
||||
<div>
|
||||
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" />
|
||||
<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" for="entry-title">Beschreibung</label>
|
||||
<div>
|
||||
<input id="entry-title" 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")" />
|
||||
<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>
|
||||
|
||||
@@ -44,9 +43,12 @@
|
||||
|
||||
@if (OutsideYear && date != default)
|
||||
{
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -58,5 +60,6 @@
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
|
||||
|
||||
@@ -45,6 +45,9 @@ public partial class AddEntryDialog
|
||||
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;
|
||||
|
||||
@@ -64,6 +67,7 @@ public partial class AddEntryDialog
|
||||
title = EditEntry.Title;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
initialState = CurrentState;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -72,6 +76,8 @@ public partial class AddEntryDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
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;
|
||||
@@ -106,7 +112,7 @@ public partial class AddEntryDialog
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving) await OnCancel.InvokeAsync();
|
||||
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
|
||||
<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>
|
||||
|
||||
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
||||
<p id="transfer-direction"><strong>Von: @FromName → Nach: @ToName</strong></p>
|
||||
<p>@CurrentAccountName bleibt fest. @(IsIncoming ? "Das abgebende Konto ist änderbar." : "Das empfangende Konto ist änderbar.")</p>
|
||||
@if (accounts.Count < 2)
|
||||
{
|
||||
<p role="status">Für eine Umbuchung brauchst du ein zweites Konto. Lege es in der Kontenübersicht an.</p>
|
||||
}
|
||||
@if (ValidAmount && targetAccountId > 0)
|
||||
{
|
||||
<p role="status">@FromName −@Money(amount), @ToName +@Money(amount); Gesamtsumme unverändert.</p>
|
||||
}
|
||||
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||
<div class="form-container">
|
||||
<label class="form-label">Zielkonto</label>
|
||||
<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))
|
||||
@@ -20,22 +29,25 @@
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="form-label">Datum</label>
|
||||
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
|
||||
<label class="form-label" for="transfer-date">Datum</label>
|
||||
<BookingDateInput @ref="bookingDate" Id="transfer-date" class="form-control" @bind-Value="dateText" PendingChangesChanged="pending => pendingDateChanges = pending" />
|
||||
|
||||
<label class="form-label">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" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<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)
|
||||
@if (OutsideYear && date != default)
|
||||
{
|
||||
<div class="alert alert-warning" role="status">
|
||||
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -47,6 +59,6 @@
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; }
|
||||
|
||||
@@ -42,6 +42,9 @@ public partial class AddTransferDialog
|
||||
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;
|
||||
|
||||
@@ -49,6 +52,13 @@ public partial class AddTransferDialog
|
||||
|
||||
#region Properties
|
||||
|
||||
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
|
||||
@@ -72,6 +82,7 @@ public partial class AddTransferDialog
|
||||
title = EditEntry.Title;
|
||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
initialState = CurrentState;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -80,6 +91,8 @@ public partial class AddTransferDialog
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
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;
|
||||
@@ -114,7 +127,7 @@ public partial class AddTransferDialog
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (!isSaving) await OnCancel.InvokeAsync();
|
||||
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" Busy="Busy">
|
||||
<h5>@Title</h5>
|
||||
<p>@Message</p>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||
<button type="button" disabled="@Busy" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
||||
<button type="button" disabled="@Busy" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class ConfirmDialog
|
||||
{
|
||||
[Parameter] public bool Busy { get; set; }
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
@@ -34,9 +35,9 @@ public partial class ConfirmDialog
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Confirm() => await OnConfirm.InvokeAsync();
|
||||
private async Task Confirm() { if (!Busy) await OnConfirm.InvokeAsync(); }
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
||||
<div class="form-container">
|
||||
<label class="form-label">Übertrag (€)</label>
|
||||
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
@inject ToastService Toasts
|
||||
<ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amountText != CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")))" Busy="saving">
|
||||
<h5>Bestandskorrektur – @AccountName</h5>
|
||||
<p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
|
||||
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
|
||||
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
|
||||
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
|
||||
<AmountInput Id="carryover" Value="@amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
|
||||
<div class="correction-preview">
|
||||
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
|
||||
<p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p>
|
||||
<p>Kontostand der Ansicht: @Money(CurrentEndBalance) → @Money(CurrentEndBalance + Delta)</p>
|
||||
<p><strong>Alle Kontostände dieses Kontos ändern sich um @Money(Delta). Das betrifft frühere und spätere Jahre.</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label><input type="checkbox" @bind="acknowledged" disabled="@saving" /> Ich habe die Auswirkungen auf alle Jahre geprüft.</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-success" disabled="@(saving || !ValidAmount || !acknowledged || Delta == 0m)" @onclick="Save">Korrektur speichern</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@saving" data-safe-focus @onclick="Cancel">Abbrechen</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using Duempelkas.App.Services;
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class EditCarryoverDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public decimal CurrentAmount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<decimal> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private decimal amount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => amount = CurrentAmount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save() => await OnSave.InvokeAsync(amount);
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
[Parameter] public decimal CurrentAmount { get; set; }
|
||||
[Parameter] public decimal InitialBalance { get; set; }
|
||||
[Parameter] public decimal CurrentEndBalance { get; set; }
|
||||
[Parameter] public int? Year { get; set; }
|
||||
[Parameter] public string AccountName { get; set; } = "Konto";
|
||||
[Parameter] public EventCallback<decimal> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
private string amountText = string.Empty;
|
||||
private bool acknowledged;
|
||||
private bool initialized;
|
||||
private bool saving;
|
||||
private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
|
||||
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
|
||||
private decimal Delta => amount - CurrentAmount;
|
||||
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
|
||||
}
|
||||
private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
|
||||
private async Task Save()
|
||||
{
|
||||
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
|
||||
saving = true;
|
||||
try { await OnSave.InvokeAsync(amount); }
|
||||
catch (Exception ex) { Toasts.Show("Die Korrektur konnte nicht gespeichert werden. Bitte den aktuellen Bestand prüfen.", ToastKind.Error, ex.Message); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
|
||||
<h5 class="mb-3">@DialogTitle</h5>
|
||||
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||
<div class="form-container">
|
||||
<label class="form-label">@NameLabel</label>
|
||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||
<label class="form-label" for="edit-name">@NameLabel</label>
|
||||
<input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
|
||||
@code { private ModalDialog? modal; }
|
||||
|
||||
@@ -31,7 +31,8 @@ public partial class EditNameDialog
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => name = CurrentName;
|
||||
private bool initialized;
|
||||
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -45,7 +46,7 @@ public partial class EditNameDialog
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,36 +1,45 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inherits LayoutComponentBase
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<div class="app-shell">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
<a class="navbar-brand" href="">
|
||||
<strong>Dümpelkas</strong> <small class="text-muted">Kassenbuch</small>
|
||||
</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>
|
||||
|
||||
<main class="flex-grow-1 overflow-auto p-3">
|
||||
@Body
|
||||
<AppErrorBoundary @key="Navigation.Uri">@Body</AppErrorBoundary>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@if (showAboutDialog)
|
||||
{
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
|
||||
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
||||
<p class="mb-2">Entwickler: Andre Beging</p>
|
||||
<p class="mb-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>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAboutDialog">
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
using Microsoft.JSInterop;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Duempelkas.App.Components.Layout;
|
||||
|
||||
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
|
||||
|
||||
private bool showAboutDialog;
|
||||
@@ -12,6 +24,19 @@ public partial class MainLayout
|
||||
|
||||
#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
|
||||
{
|
||||
get
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
</Found>
|
||||
<NotFound>
|
||||
<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>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@implements IDisposable
|
||||
@inject ToastService Toasts
|
||||
|
||||
<div class="toast-stack" aria-label="Meldungen">
|
||||
<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()"
|
||||
@@ -23,6 +23,7 @@
|
||||
</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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@inject ToastService Toasts
|
||||
@implements IDisposable
|
||||
@inject ReportingPeriod Period
|
||||
@page "/accounts/{AccountId:int}"
|
||||
@inject IAccountService AccountService
|
||||
@@ -21,7 +22,7 @@
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<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
|
||||
<button class="btn-edit-pen" @onclick="() => showEditName = true" title="Name bearbeiten"><i class="bi bi-pencil"></i></button>
|
||||
</h1>
|
||||
@@ -96,7 +97,7 @@
|
||||
@if (entries != null)
|
||||
{
|
||||
<div class="entries-scroll-wrapper">
|
||||
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="@emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -125,7 +126,7 @@
|
||||
|
||||
@if (showEditCarryover)
|
||||
{
|
||||
<EditCarryoverDialog CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
|
||||
<EditCarryoverDialog AccountName="@(account?.Name ?? "")" Year="selectedYear" InitialBalance="@(account?.CarryoverBalance ?? 0)" CurrentEndBalance="@(balance?.TotalBalance ?? 0)" CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
|
||||
OnSave="HandleSaveCarryover"
|
||||
OnCancel="() => showEditCarryover = false" />
|
||||
}
|
||||
@@ -133,7 +134,7 @@
|
||||
@if (confirmDeleteEntryId.HasValue)
|
||||
{
|
||||
<ConfirmDialog Title="Buchung löschen"
|
||||
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")"
|
||||
Message="@(EntryChangeMessage(confirmDeleteEntryId, false))" Busy="entryActionBusy"
|
||||
ConfirmText="Ja, löschen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-danger"
|
||||
@@ -145,7 +146,7 @@
|
||||
@if (confirmRestoreEntryId.HasValue)
|
||||
{
|
||||
<ConfirmDialog Title="Buchung wiederherstellen"
|
||||
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")"
|
||||
Message="@(EntryChangeMessage(confirmRestoreEntryId, true))" Busy="entryActionBusy"
|
||||
ConfirmText="Ja, wiederherstellen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-warning"
|
||||
@@ -184,8 +185,7 @@
|
||||
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
@@ -204,7 +204,6 @@
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace Duempelkas.App.Pages.Accounts;
|
||||
|
||||
public partial class AccountDetail
|
||||
{
|
||||
private bool disposed;
|
||||
public void Dispose() => disposed = true;
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
@@ -30,6 +32,8 @@ public partial class AccountDetail
|
||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||
private List<int> availableYears = [];
|
||||
|
||||
private bool entryActionBusy;
|
||||
private Guid? undoToastId;
|
||||
private int? confirmDeleteEntryId;
|
||||
private string? confirmDeleteEntryTitle;
|
||||
|
||||
@@ -108,9 +112,10 @@ public partial class AccountDetail
|
||||
|
||||
private async Task HandleSaveCarryover(decimal newAmount)
|
||||
{
|
||||
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
|
||||
showEditCarryover = false;
|
||||
await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance);
|
||||
await LoadAll();
|
||||
showEditCarryover = false;
|
||||
Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success);
|
||||
}
|
||||
|
||||
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||
@@ -144,19 +149,27 @@ public partial class AccountDetail
|
||||
|
||||
private void CancelDeleteConfirm()
|
||||
{
|
||||
if (entryActionBusy) return;
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDelete()
|
||||
{
|
||||
if (confirmDeleteEntryId.HasValue)
|
||||
if (entryActionBusy || !confirmDeleteEntryId.HasValue) return;
|
||||
entryActionBusy = true;
|
||||
try
|
||||
{
|
||||
await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value);
|
||||
var id = confirmDeleteEntryId.Value;
|
||||
await EntryService.DeleteEntryAsync(id);
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
await LoadAll();
|
||||
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||
undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success,
|
||||
actionLabel: "Rückgängig", action: 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)
|
||||
@@ -167,19 +180,64 @@ public partial class AccountDetail
|
||||
|
||||
private void CancelRestoreConfirm()
|
||||
{
|
||||
if (entryActionBusy) return;
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (confirmRestoreEntryId.HasValue)
|
||||
if (entryActionBusy || !confirmRestoreEntryId.HasValue) return;
|
||||
entryActionBusy = true;
|
||||
try
|
||||
{
|
||||
await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value);
|
||||
var id = confirmRestoreEntryId.Value;
|
||||
await EntryService.RestoreEntryAsync(id);
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
await LoadAll();
|
||||
if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value);
|
||||
undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success,
|
||||
actionLabel: "Rückgängig", action: 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)
|
||||
@@ -200,7 +258,7 @@ public partial class AccountDetail
|
||||
private async Task HandleExport()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -219,7 +277,7 @@ public partial class AccountDetail
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<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
|
||||
<button class="btn-edit-pen" @onclick="() => showEditClubName = true" title="Vereinsname bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -71,6 +71,7 @@
|
||||
<div class="text-center py-5 text-muted">
|
||||
<h4>Noch keine Konten vorhanden</h4>
|
||||
<p>Erstelle dein erstes Konto, um loszulegen.</p>
|
||||
<a class="btn btn-primary" href="einstieg">Kurze Einführung starten oder fortsetzen</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -116,8 +117,7 @@
|
||||
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
@@ -136,7 +136,6 @@
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ public partial class Dashboard
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task HandleAccountCreated(string name)
|
||||
private async Task HandleAccountCreated(AccountSetup setup)
|
||||
{
|
||||
await AccountService.CreateAccountAsync(name);
|
||||
await AccountService.CreateAccountAsync(setup.Name, setup.InitialBalance);
|
||||
showAddAccount = false;
|
||||
await LoadAll();
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public partial class Dashboard
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -127,7 +127,7 @@ public partial class Dashboard
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -144,7 +144,7 @@ public partial class Dashboard
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -180,7 +180,7 @@ public partial class Dashboard
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
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;
|
||||
fileResult = null;
|
||||
try
|
||||
|
||||
@@ -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>
|
||||
@@ -20,6 +20,14 @@ public static partial class BookingInput
|
||||
&& 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;
|
||||
|
||||
|
||||
@@ -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<AccountSummaryDto> GetAccountAsync(int accountId);
|
||||
Task<AccountSummaryDto> CreateAccountAsync(string name);
|
||||
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
|
||||
Task RenameAccountAsync(int accountId, string newName);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
|
||||
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
|
||||
Task DeleteAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ namespace Duempelkas.App.Services;
|
||||
|
||||
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 SetClubNameAsync(string? clubName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Duempelkas.App.Services.Models;
|
||||
|
||||
public sealed record AccountSetup(string Name, decimal InitialBalance);
|
||||
@@ -21,6 +21,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyMetadata Include="BuildDate" Value="$([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ'))" />
|
||||
<Content Include="wwwroot\**">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
@@ -66,6 +66,9 @@ class Program
|
||||
|
||||
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;
|
||||
|
||||
// Force the native window icon for title bar + taskbar representation.
|
||||
@@ -120,7 +123,7 @@ class Program
|
||||
|
||||
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();
|
||||
|
||||
@@ -350,8 +350,9 @@ html[data-ui-theme="light"] .btn-dark {
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: var(--color-surface);
|
||||
border-bottom-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
@@ -629,4 +630,88 @@ 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; }
|
||||
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
|
||||
.input-rejection:empty { display: none; }
|
||||
|
||||
.app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; }
|
||||
.app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); }
|
||||
.dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.account-card { display: block; width: 100%; color: inherit; text-decoration: none; }
|
||||
:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
|
||||
fieldset { border: 0; margin: 0; padding: 0; }
|
||||
|
||||
body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
|
||||
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
|
||||
.entry-actions button { 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,21 +109,17 @@
|
||||
})();
|
||||
</script>
|
||||
<title>Dümpelkas – Kassenbuch</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.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="vendor/bootstrap/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="vendor/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet" />
|
||||
<link href="css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<app>Laden...</app>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
Ein unbehandelter Fehler ist aufgetreten.
|
||||
<a href="" class="reload">Neu laden</a>
|
||||
<a class="dismiss">×</a>
|
||||
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">Ansicht neu laden</a>
|
||||
<button type="button" class="dismiss" aria-label="Fehlerhinweis schließen">×</button>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
}
|
||||
function accept(input, text) {
|
||||
const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd);
|
||||
const ok = allowed(input, next);
|
||||
const hint = document.getElementById(input.id + '-rejected');
|
||||
if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.';
|
||||
return ok;
|
||||
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();
|
||||
@@ -17,3 +14,27 @@
|
||||
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
@@ -93,15 +93,19 @@ public class AccountService : IAccountService
|
||||
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
|
||||
public Task<AccountSummaryDto> CreateAccountAsync(string name) => CreateAccountAsync(name, 0m);
|
||||
|
||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Bitte einen Kontonamen eingeben.");
|
||||
if (decimal.Round(initialBalance, 2) != initialBalance) throw new ArgumentException("Ungültiger Anfangsbestand.");
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = new Account { Name = name };
|
||||
var account = new Account { Name = name.Trim(), CarryoverBalance = initialBalance };
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc);
|
||||
return new AccountSummaryDto(account.Id, account.Name, initialBalance, initialBalance, account.CreatedUtc);
|
||||
}
|
||||
|
||||
public async Task RenameAccountAsync(int accountId, string newName)
|
||||
@@ -148,6 +152,22 @@ public class AccountService : IAccountService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial)
|
||||
{
|
||||
if (decimal.Round(value, 2) != value) throw new ArgumentException("Ungültiger Bestand.");
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var account = await db.Accounts.FindAsync(accountId) ?? throw new InvalidOperationException("Konto nicht mehr vorhanden.");
|
||||
var movement = year.HasValue ? await db.Entries.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value)
|
||||
.SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount) : 0m;
|
||||
if (account.CarryoverBalance != expectedInitial || account.CarryoverBalance + movement != expectedCarryover)
|
||||
throw new InvalidOperationException("Der Bestand wurde inzwischen geändert. Bitte abbrechen und die Korrektur mit aktuellen Werten erneut öffnen.");
|
||||
account.CarryoverBalance = value - movement;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(int accountId)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||
|
||||
@@ -270,6 +270,11 @@ public class EntryService : IEntryService
|
||||
var otherEntry = await db.Entries.FindAsync(otherEntryId)
|
||||
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
|
||||
|
||||
if (newLinkedAccountId == entry.AccountId)
|
||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||
if (!await db.Accounts.AnyAsync(a => a.Id == newLinkedAccountId))
|
||||
throw new InvalidOperationException("Das gewählte Konto ist nicht mehr vorhanden.");
|
||||
|
||||
// Update date, title, amount on both sides
|
||||
entry.Date = date;
|
||||
entry.Title = title.Trim();
|
||||
|
||||
@@ -12,6 +12,15 @@ public class SettingsService : ISettingsService
|
||||
|
||||
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()
|
||||
{
|
||||
var settings = await LoadAsync();
|
||||
@@ -25,6 +34,14 @@ public class SettingsService : ISettingsService
|
||||
await SaveAsync(settings);
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -51,6 +68,8 @@ public class SettingsService : ISettingsService
|
||||
|
||||
private class AppSettings
|
||||
{
|
||||
public bool ShowDeletedEntries { get; set; }
|
||||
public string? ClubName { 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,10 +11,31 @@ 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"));
|
||||
@@ -30,9 +51,11 @@ public class BookingFormTests
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -40,6 +63,7 @@ public class BookingFormTests
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,8 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
||||
|
||||
private sealed class FixedSettingsService : ISettingsService
|
||||
{
|
||||
public Task<bool> GetShowDeletedEntriesAsync() => Task.FromResult(false);
|
||||
public Task SetShowDeletedEntriesAsync(bool show) => Task.CompletedTask;
|
||||
private readonly 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, "src", "Duempelkas.Desktop", "Duempelkas.Desktop.csproj"))) 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")));
|
||||
}
|
||||
}
|
||||
@@ -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,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, "src", "Duempelkas.Desktop", "Duempelkas.Desktop.csproj"))) 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
control.Find("input").Input("12,501");
|
||||
Assert.Equal("12,50", amount);
|
||||
Assert.Contains("nicht übernommen", control.Markup);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -34,18 +34,20 @@ public class YearNavigationTests
|
||||
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
|
||||
var page = context.Render<Routes>();
|
||||
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
|
||||
page.Find(".account-card").Click();
|
||||
page.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.Find(".account-card").Click();
|
||||
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();
|
||||
@@ -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.Contains("Zurück")).Click();
|
||||
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));
|
||||
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user