Compare commits

...
4 Commits
26 changed files with 714 additions and 139 deletions
+22
View File
@@ -27,3 +27,25 @@ Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen
## U03 – Gleiche Bedeutung in PDF und Oberfläche
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
## U04 – Jahreskontext
Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück erhalten, auch „Alle Jahre“. Auswahl enthält aktuelles und gewähltes Jahr auch ohne Buchungen. Liste und Summen benennen den Zeitraum; Leerzustände unterscheiden fehlende Buchungen und leeren Zeitraum. Abweichendes Datum bleibt unverändert, erfordert eine gezielte Bestätigung im Formular und bietet nach Erfolg „Buchung im Jahr … anzeigen“. Prüfung: 21/21 Debug-Tests, einschließlich gerenderter Navigation 2025 → Konto → Zurück, Buchung 2026 bei Filter 2025 und anschließendem Aufruf des gespeicherten Jahres. Dabei bestehende doppelte DbContext-Registrierung korrigiert, da die Singleton-Factory sonst scoped Optionen konsumiert.
## U05 – Alltagssprache
Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt.
## U06 – Verständliche Eingabe
Konto im Dialogtitel, sichtbare Art beim Bearbeiten und erklärter Korrekturweg: abbrechen, falsche Buchung löschen, neu erfassen. Keine Änderung der Buchungsart bestehender Daten. Feldnahe, technisch zugeordnete Hinweise für Beschreibung, Datum und Betrag. Komma/Punkt sowie eingefügte einfache Beträge mit Rand-Leerzeichen werden akzeptiert; Minus, Tausendertrenner, Währungszeichen, Überlauf und mehr als zwei Nachkommastellen abgewiesen, ohne Rundung. Dienste prüfen dieselben fachlichen Mindestregeln. Nach erneutem Rendern bleiben geänderte Eingaben erhalten. Prüfung: 35/35 Debug-Tests, darunter Parsing-Grenzfälle und gerenderte Formularprüfung mit Korrektur ungültiger Eingaben. Längere Buchungsdialoge können innerhalb des Fensters scrollen.
## U07 – Speichern und Mehrfachauslösung
Beide Buchungsdialoge sperren Formular und Aktionen während des Speicherns und zeigen „Wird gespeichert …“. Submit (einschließlich Enter) verwendet denselben geschützten Ablauf. Nach Erfolg meldet die Kontoseite Art und Betrag und hebt die betroffene Zeile kurz hervor. Transfererfassung liefert die eindeutige Quellbuchungs-ID zurück. Fehler halten Eingaben im geöffneten Dialog; ein fehlgeschlagener Seitenrefresh nach bereits bestätigtem Schreiben kann ausschließlich den Refresh wiederholen. Eine bewusste weitere Erfassung bleibt möglich. Optionale Serienerfassung wurde nicht hinzugefügt.
Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbuchung mit mehrfachen Submit-Ereignissen erzeugen jeweils genau eine fachliche Buchung; zweite bewusste Erfassung funktioniert. Simulierte Fehler behalten Beschreibung und Dezimaltext, erzeugen keine falsche Erfolgsmeldung und erlauben Retry. Gesonderter Test prüft Refreshfehler nach Commit ohne erneutes Schreiben. Jahresnavigation einschließlich „Alle Jahre“ ist ebenfalls gerendert geprüft.
## Abschluss und Grenzen
Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme.
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryRow
{
[Parameter] public bool Highlighted { get; set; }
#region Parameters
[Parameter]
@@ -26,6 +28,7 @@ public partial class EntryRow
private string GetRowClass()
{
var classes = new List<string>();
if (Highlighted) classes.Add("entry-recent");
if (Entry.IsTransfer)
{
classes.Add("entry-row-transfer");
@@ -12,7 +12,7 @@
<tbody>
@foreach (var entry in Entries)
{
<EntryRow Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
}
</tbody>
</table>
@@ -20,6 +20,6 @@
@if (!Entries.Any())
{
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div>
<div class="text-center py-3 text-muted">@EmptyMessage</div>
}
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable
{
[Parameter] public int? HighlightedEntryId { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters
[Parameter]
@@ -1,36 +1,68 @@
@inject IEntryService EntryService
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
<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")">
<fieldset disabled="@(isSaving || committedResult != null || completed)">
<div class="form-container">
<label class="form-label" for="entry-type">Art</label>
@if (EditEntry == null)
{
<label class="form-label">Art</label>
<select @bind="entryType">
<select id="entry-type" @bind="entryType">
<option value="@EntryType.Income">Einnahme</option>
<option value="@EntryType.Expense">Ausgabe</option>
</select>
}
else
{
<div><strong>@(entryType == EntryType.Income ? "Einnahme" : "Ausgabe")</strong>
<p class="text-muted">Falsche Art? Diese Buchung abbrechen, in der Kontoliste löschen und mit der richtigen Art neu erfassen.</p>
</div>
}
<label class="form-label">Datum</label>
<input type="date" @bind="date" />
<label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
<label class="form-label">Betrag (€)</label>
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
<label class="form-label" for="entry-date">Datum</label>
<div>
<input id="entry-date" type="date" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" aria-describedby="entry-date-help" aria-invalid="@(date == default ? "true" : "false")" />
@if (date == default) { <p id="entry-date-help" class="text-danger">Bitte ein gültiges Datum eingeben.</p> }
</div>
<label class="form-label" 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")" />
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
</div>
<label class="form-label" for="entry-amount">Betrag (€)</label>
<div>
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
</div>
</div>
@if (OutsideYear && date != default)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
</fieldset>
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
@if (saveError != null)
{
<div class="alert alert-danger" role="alert">@saveError
<details><summary>Technische Details</summary>@saveDetails</details>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</div>
</div>
@@ -15,19 +15,38 @@ public partial class AddEntryDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
public EventCallback<BookingSaved> OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
#endregion
[Parameter] public int? ContextYear { get; set; }
private bool confirmDifferentYear;
private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
[Parameter] public string AccountName { get; set; } = string.Empty;
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
private bool isSaving;
private bool completed;
private BookingSaved? committedResult;
private string? saveError;
private string? saveDetails;
#region Fields
private EntryType entryType = EntryType.Income;
private DateTime date = DateTime.Today;
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty;
private decimal amount;
private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private bool initialized;
private int? initializedEntryId;
#endregion
@@ -35,12 +54,15 @@ public partial class AddEntryDialog
protected override void OnParametersSet()
{
if (initialized && initializedEntryId == EditEntry?.Id) return;
initialized = true;
initializedEntryId = EditEntry?.Id;
if (EditEntry != null)
{
entryType = EditEntry.Type;
date = EditEntry.Date;
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title;
amount = EditEntry.Amount;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
}
@@ -50,19 +72,40 @@ public partial class AddEntryDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
isSaving = true;
saveError = null;
saveDetails = null;
try
{
if (committedResult == null)
{
var id = EditEntry?.Id ?? 0;
if (EditEntry != null)
{
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
}
else
id = (await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount)).Id;
committedResult = new(id, date, entryType == EntryType.Income ? "Einnahme" : "Ausgabe", amount, EditEntry != null);
}
// If refreshing the page fails after commit, retry only notification,
// never create a second booking.
await OnSave.InvokeAsync(committedResult);
completed = true;
}
catch (Exception ex)
{
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
saveError = committedResult == null
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
saveDetails = ex.Message;
}
finally { isSaving = false; }
}
await OnSave.InvokeAsync();
private async Task Cancel()
{
if (!isSaving) await OnCancel.InvokeAsync();
}
private async Task Cancel() => await OnCancel.InvokeAsync();
#endregion
}
@@ -2,12 +2,16 @@
@inject IAccountService AccountService
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
<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>
<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>
<select class="form-select" @bind="targetAccountId">
<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))
{
@@ -16,20 +20,39 @@
</select>
<label class="form-label">Datum</label>
<input type="date" class="form-control" @bind="date" />
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
<label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
<label class="form-label">Betrag (€)</label>
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
@if (OutsideYear)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
</fieldset>
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
@if (saveError != null)
{
<div class="alert alert-danger" role="alert">@saveError
<details><summary>Technische Details</summary>@saveDetails</details>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
</button>
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</div>
</div>
@@ -14,26 +14,42 @@ public partial class AddTransferDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
public EventCallback<BookingSaved> OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
#endregion
[Parameter] public int? ContextYear { get; set; }
private bool confirmDifferentYear;
private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
private bool isSaving;
private bool completed;
private BookingSaved? committedResult;
private string? saveError;
private string? saveDetails;
#region Fields
private List<AccountSummaryDto> accounts = new();
private int targetAccountId;
private DateTime date = DateTime.Today;
private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty;
private decimal amount;
private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private bool initialized;
private int? initializedEntryId;
#endregion
#region Properties
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
#endregion
@@ -46,12 +62,15 @@ public partial class AddTransferDialog
accounts = await AccountService.GetAllAccountsAsync();
}
if (initialized && initializedEntryId == EditEntry?.Id) return;
initialized = true;
initializedEntryId = EditEntry?.Id;
if (EditEntry != null)
{
targetAccountId = EditEntry.LinkedAccountId ?? 0;
date = EditEntry.Date;
dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title;
amount = EditEntry.Amount;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
}
@@ -61,19 +80,40 @@ public partial class AddTransferDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
isSaving = true;
saveError = null;
saveDetails = null;
try
{
if (committedResult == null)
{
var id = EditEntry?.Id ?? 0;
if (EditEntry != null)
{
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
}
else
id = await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
committedResult = new(id, date, "Umbuchung", amount, EditEntry != null);
}
// If refreshing the page fails after commit, retry only notification,
// never create a second booking.
await OnSave.InvokeAsync(committedResult);
completed = true;
}
catch (Exception ex)
{
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
saveError = committedResult == null
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
saveDetails = ex.Message;
}
finally { isSaving = false; }
}
await OnSave.InvokeAsync();
private async Task Cancel()
{
if (!isSaving) await OnCancel.InvokeAsync();
}
private async Task Cancel() => await OnCancel.InvokeAsync();
#endregion
}
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/accounts/{AccountId:int}"
@inject IAccountService AccountService
@inject IEntryService EntryService
@@ -10,6 +11,19 @@
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (lastSaved != null)
{
<div class="alert alert-success" role="status" aria-live="polite">
@lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert").
</div>
}
@if (savedOutsideYear.HasValue)
{
<div class="alert alert-info" role="status">
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
</div>
}
@if (account == null)
{
<div class="text-center py-5">
@@ -31,28 +45,27 @@
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
<i class="bi bi-arrow-left"></i> Zurück
</button>
|
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
<i class="bi bi-plus-lg"></i> Buchung
<i class="bi bi-plus-lg"></i> Einnahme oder Ausgabe erfassen
</button>
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung
<i class="bi bi-arrow-left-right"></i> Geld zwischen Konten verschieben
</button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
</button>
|
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
<i class="bi bi-funnel"></i> @GetFilterLabel()
</button>
</div>
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
@if (balance != null)
{
<div class="summary-section">
<div class="summary-flex">
<div>
<small class="text-muted">Saldo</small>
<small class="text-muted">Kontostand</small>
<div class="fs-3 fw-bold @(balance.TotalBalance >= 0 ? "amount-positive" : "amount-negative")">
@FormatAmount(balance.TotalBalance)
</div>
@@ -82,7 +95,7 @@
</div>
</div>
<div class="text-end">
<small class="text-muted">Umsätze @selectedYear.Value</small>
<small class="text-muted">Änderung im Jahr @selectedYear.Value</small>
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
</div>
@@ -96,7 +109,7 @@
@if (entries != null)
{
<div class="entries-scroll-wrapper">
<EntryTable Entries="entries" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
<EntryTable HighlightedEntryId="lastSaved?.EntryId" Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
</div>
}
}
@@ -104,14 +117,14 @@
@if (showAddEntry)
{
<AddEntryDialog AccountId="AccountId"
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleEntryCreated"
OnCancel="() => showAddEntry = false" />
}
@if (showAddTransfer)
{
<AddTransferDialog SourceAccountId="AccountId"
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleTransferCreated"
OnCancel="() => showAddTransfer = false" />
}
@@ -157,7 +170,7 @@
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
<ConfirmDialog Title="PDF öffnen"
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
ConfirmText="Ja, öffnen"
CancelText="Nein"
ConfirmButtonClass="btn btn-primary"
@@ -168,7 +181,7 @@
@if (editingEntry != null)
{
<AddEntryDialog AccountId="AccountId"
<AddEntryDialog AccountName="@(account?.Name ?? "")" AccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingEntry"
OnSave="HandleEntryEdited"
OnCancel="() => editingEntry = null" />
@@ -176,7 +189,7 @@
@if (editingTransferEntry != null)
{
<AddTransferDialog SourceAccountId="AccountId"
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingTransferEntry"
OnSave="HandleEntryEdited"
OnCancel="() => editingTransferEntry = null" />
@@ -22,7 +22,7 @@ public partial class AccountDetail
private bool showEditName;
private bool showEditCarryover;
private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year;
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List<int> availableYears = [];
private int? confirmDeleteEntryId;
@@ -35,6 +35,9 @@ public partial class AccountDetail
private FileOperationResult? fileResult;
private bool fileBusy;
private int? savedOutsideYear;
private BookingSaved? lastSaved;
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
private EntryDto? editingEntry;
private EntryDto? editingTransferEntry;
@@ -62,7 +65,9 @@ public partial class AccountDetail
account = await AccountService.GetAccountAsync(AccountId);
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
availableYears = await EntryService.GetEntryYearsAsync(AccountId);
var entryYears = await EntryService.GetEntryYearsAsync(AccountId);
availableYears = Period.Choices(entryYears);
emptyEntriesMessage = entryYears.Count == 0 ? "Noch keine Buchungen vorhanden." : "Keine Buchungen in diesem Zeitraum. Wähle ein anderes Jahr oder Alle Jahre.";
}
private void OpenYearFilterDialog()
@@ -78,6 +83,7 @@ public partial class AccountDetail
private async Task SelectFilterYear(int? year)
{
selectedYear = year;
savedOutsideYear = null;
showYearFilterDialog = false;
await LoadAll();
}
@@ -100,17 +106,22 @@ public partial class AccountDetail
await LoadAll();
}
private async Task HandleEntryCreated()
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
private Task HandleTransferCreated(BookingSaved saved) => HandleBookingSaved(saved);
private async Task HandleBookingSaved(BookingSaved saved)
{
showAddEntry = false;
await LoadAll();
lastSaved = saved;
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null;
showAddEntry = false;
showAddTransfer = false;
editingEntry = null;
editingTransferEntry = null;
}
private async Task HandleTransferCreated()
{
showAddTransfer = false;
await LoadAll();
}
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
private void RequestDeleteEntry(int entryId)
{
@@ -171,12 +182,7 @@ public partial class AccountDetail
}
}
private async Task HandleEntryEdited()
{
editingEntry = null;
editingTransferEntry = null;
await LoadAll();
}
private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved);
private async Task HandleExport()
{
@@ -224,9 +230,9 @@ public partial class AccountDetail
#region Helpers
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
private static string FormatAmount(decimal amount) => amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
private string GetFilterLabel() => Period.Label;
#endregion
}
+15 -13
View File
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/"
@inject IAccountService AccountService
@inject IBackupService BackupService
@@ -27,30 +28,31 @@
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
<i class="bi bi-plus-lg"></i> Neues Konto
</button>
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
<i class="bi bi-save"></i> Backup
<i class="bi bi-save"></i> Sicherung erstellen
</button>
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
</button>
|
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
<i class="bi bi-funnel"></i> @GetFilterLabel()
</button>
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
</button>
<details class="more-actions">
<summary class="btn btn-outline-secondary">Weitere Aktionen</summary>
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
</button>
</details>
</div>
@if (accounts != null && accounts.Any())
{
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
<strong>Summe aller Konten:</strong>
<strong>Summe aller Konten – @Period.Label:</strong>
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
@FormatCurrency(TotalClubBalance)
</span>
@@ -91,7 +93,7 @@
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
<ConfirmDialog Title="PDF öffnen"
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
Message="Die PDF wurde gespeichert. Möchtest du sie jetzt öffnen?"
ConfirmText="Ja, öffnen"
CancelText="Nein"
ConfirmButtonClass="btn btn-primary"
@@ -102,8 +104,8 @@
@if (showRestoreConfirm)
{
<ConfirmDialog Title="Restore bestätigen"
Message="Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?"
<ConfirmDialog Title="Sicherung zurückspielen"
Message="Die Buchungen und Konten werden durch die gewählte Sicherung ersetzt. Vorher wird eine Rückfallsicherung erstellt. Vereinsname und Darstellung sind nicht enthalten. Möchtest du fortfahren?"
ConfirmText="Ja, wiederherstellen"
CancelText="Nein, abbrechen"
ConfirmButtonClass="btn btn-warning"
+3 -3
View File
@@ -14,7 +14,7 @@ public partial class Dashboard
private bool showEditClubName;
private bool showRestoreConfirm;
private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year;
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List<int> availableYears = [];
private string clubName = string.Empty;
private string? savedPdfPath;
@@ -67,7 +67,7 @@ public partial class Dashboard
private async Task LoadAll()
{
await LoadAccounts();
availableYears = await EntryService.GetAllEntryYearsAsync();
availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
}
private async Task LoadAccounts()
@@ -207,7 +207,7 @@ public partial class Dashboard
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
}
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
private string GetFilterLabel() => Period.Label;
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
@@ -0,0 +1,30 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Duempelkas.App.Services;
public static partial class BookingInput
{
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
private static partial Regex AmountPattern();
public static bool TryAmount(string text, out decimal amount)
{
amount = 0;
var plain = text.Trim();
return AmountPattern().IsMatch(plain)
&& decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)
&& amount > 0;
}
public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
public static void Validate(DateTime date, string title, decimal amount)
{
if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben.");
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben.");
if (amount <= 0 || decimal.Round(amount, 2) != amount)
throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben.");
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ public interface IEntryService
Task<List<int>> GetEntryYearsAsync(int accountId);
Task<List<int>> GetAllEntryYearsAsync();
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
Task DeleteEntryAsync(int entryId);
Task RestoreEntryAsync(int entryId);
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
@@ -0,0 +1,3 @@
namespace Duempelkas.App.Services.Models;
public sealed record BookingSaved(int EntryId, DateTime Date, string Kind, decimal Amount, bool Edited);
@@ -0,0 +1,11 @@
namespace Duempelkas.App.Services;
/// <summary>The user's explicit selection survives navigation, including All years.</summary>
public sealed class ReportingPeriod
{
public int? Year { get; set; } = DateTime.Today.Year;
public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
public List<int> Choices(IEnumerable<int> entryYears) => entryYears
.Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty<int>())
.Distinct().OrderDescending().ToList();
}
+1 -1
View File
@@ -46,7 +46,7 @@ class Program
}
catch (Exception ex)
{
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex);
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht übernommen werden. Der Start wurde abgebrochen; die ursprüngliche Datei bleibt erhalten.", ex);
}
}
+26 -2
View File
@@ -217,8 +217,14 @@ html, body {
}
.btn-nav {
width: 75px;
height: 75px;
width: auto;
min-width: 7rem;
max-width: 15rem;
min-height: 75px;
height: auto;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
display: flex;
flex-direction: column;
gap: 0;
@@ -588,3 +594,21 @@ input[type="number"].no-spinner {
margin-bottom: 0.3rem;
}
.more-actions { align-self: center; }
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
.form-container > div { min-width: 0; }
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
fieldset { min-width: 0; }
.entry-recent > td { animation: saved-booking 6s ease-out; }
@keyframes saved-booking {
from { background-color: var(--color-surface-hover); box-shadow: inset 0 2px var(--color-accent), inset 0 -2px var(--color-accent); }
to { background-color: transparent; box-shadow: none; }
}
@media (prefers-reduced-motion: reduce) {
.entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); }
}
@@ -11,8 +11,8 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
// The factory also registers FinanceDbContext as scoped. Registering
// AddDbContext first would give its singleton factory scoped options.
services.AddDbContextFactory<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
@@ -24,6 +24,7 @@ public static class DependencyInjection
services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<ReportingPeriod>();
return services;
}
@@ -99,6 +99,8 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart.");
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -110,7 +112,7 @@ public class EntryService : IEntryService
DisplayId = displayId,
Type = type,
Date = date,
Title = title,
Title = title.Trim(),
Amount = amount
};
db.Entries.Add(entry);
@@ -119,8 +121,9 @@ public class EntryService : IEntryService
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
}
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
{
BookingInput.Validate(date, title, amount);
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
@@ -136,7 +139,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId,
Type = EntryType.Expense,
Date = date,
Title = title,
Title = title.Trim(),
Amount = amount
};
@@ -149,7 +152,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId,
Type = EntryType.Income,
Date = date,
Title = title,
Title = title.Trim(),
Amount = amount
};
@@ -170,6 +173,7 @@ public class EntryService : IEntryService
await db.SaveChangesAsync();
await transaction.CommitAsync();
return sourceEntry.Id;
}
public async Task DeleteEntryAsync(int entryId)
@@ -220,6 +224,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -230,7 +235,7 @@ public class EntryService : IEntryService
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date;
entry.Title = title;
entry.Title = title.Trim();
entry.Amount = amount;
if (link != null)
@@ -240,7 +245,7 @@ public class EntryService : IEntryService
if (otherEntry != null)
{
otherEntry.Date = date;
otherEntry.Title = title;
otherEntry.Title = title.Trim();
otherEntry.Amount = amount;
}
}
@@ -250,6 +255,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -266,10 +272,10 @@ public class EntryService : IEntryService
// Update date, title, amount on both sides
entry.Date = date;
entry.Title = title;
entry.Title = title.Trim();
entry.Amount = amount;
otherEntry.Date = date;
otherEntry.Title = title;
otherEntry.Title = title.Trim();
otherEntry.Amount = amount;
// If the linked account has changed, move the other entry to the new account
@@ -0,0 +1,51 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class BookingFormTests
{
[Fact]
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{
using var context = new BunitContext();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
Assert.Contains("Bitte beschreiben", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-title").Input("Porto");
dialog.Find("#entry-amount").Input("12,501");
Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value"));
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-amount").Input("-12,50");
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-amount").Input("12,50");
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("#entry-date").Input("");
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
dialog.Find("#entry-date").Input("2026-01-02");
Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
}
[Fact]
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
{
using var context = new BunitContext();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
Assert.Contains("Ausgabe", dialog.Markup);
Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
dialog.Find("#entry-title").Input("Korrigierter Text");
dialog.Render(p => p.Add(x => x.EditEntry, entry));
Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
}
}
@@ -0,0 +1,35 @@
using Duempelkas.App.Services;
using Xunit;
namespace Duempelkas.Tests;
public class BookingInputTests
{
[Theory]
[InlineData("12,50", true)]
[InlineData("12.50", true)]
[InlineData(" 12,50 ", true)]
[InlineData("12,500", false)]
[InlineData("-12,50", false)]
[InlineData("1.234,56", false)]
[InlineData("1,234.56", false)]
[InlineData("12,50 €", false)]
[InlineData("0", false)]
[InlineData("", false)]
[InlineData("9999999999999999999999999999999999999", false)]
public void DecimalInput_HasExplicitRules(string text, bool valid)
{
Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
if (valid) Assert.Equal(12.50m, amount);
}
[Fact]
public void ServiceValidation_RejectsInvalidData()
{
Assert.Throws<ArgumentException>(() => BookingInput.Validate(default, "Test", 1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, " ", 1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
Assert.Throws<ArgumentException>(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
Assert.Equal(default, BookingInput.Date("2026-02-30"));
}
}
+143
View File
@@ -0,0 +1,143 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure;
using Duempelkas.Infrastructure.Persistence;
using Duempelkas.Infrastructure.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class BookingSaveTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RepeatedSubmission_StoresOnce_ThenAllowsDeliberateNewBooking(bool transfer)
{
using var fixture = new Fixture();
var page = fixture.Context.Render<Routes>();
page.WaitForElement(".account-card").Click();
page.WaitForElement(".summary-section");
OpenAndFill(page, transfer);
var delayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.Entries.BeforeWrite = () => delayed.Task;
var pending = page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
page.WaitForAssertion(() => Assert.Contains("Wird gespeichert", page.Markup));
Assert.True(page.Find("button[type=submit]").HasAttribute("disabled"));
// Directly deliver repeated submit events, including ones a disabled UI would suppress.
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
Assert.Equal(1, fixture.Entries.WriteCalls);
delayed.SetResult();
await pending;
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".alert-success").TextContent));
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
Assert.Single(page.FindAll("tr.entry-recent"));
if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null));
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
OpenAndFill(page, transfer);
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
page.WaitForAssertion(() => Assert.Empty(page.FindAll("form")));
Assert.Equal(2, (await fixture.Entries.GetEntriesAsync(1, (int?)null)).Count);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Failure_PreservesInputs_AndRetryWorks(bool transfer)
{
using var fixture = new Fixture();
var page = fixture.Context.Render<Routes>();
page.WaitForElement(".account-card").Click();
page.WaitForElement(".summary-section");
OpenAndFill(page, transfer);
fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler"));
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
Assert.Contains("Eingaben bleiben erhalten", page.Markup);
Assert.Empty(page.FindAll(".alert-success"));
Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value"));
Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value"));
Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null));
fixture.Entries.BeforeWrite = () => Task.CompletedTask;
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Markup));
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
}
[Fact]
public async Task NotificationFailureAfterCommit_RetriesRefreshWithoutWritingAgain()
{
using var fixture = new Fixture();
var notifications = 0;
var dialog = fixture.Context.Render<AddEntryDialog>(p => p
.Add(x => x.AccountId, 1)
.Add(x => x.OnSave, (BookingSaved _) =>
{
if (++notifications == 1) throw new IOException("Refresh fehlgeschlagen");
}));
dialog.Find("#entry-title").Input("Testbuchung");
dialog.Find("#entry-amount").Input("12,50");
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
Assert.Contains("Die Buchung ist gespeichert", dialog.Markup);
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
Assert.Equal(1, fixture.Entries.WriteCalls);
Assert.Equal(2, notifications);
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
}
private static void OpenAndFill(IRenderedComponent<Routes> page, bool transfer)
{
page.FindAll("button").Single(b => b.TextContent.Contains(transfer ? "Geld zwischen Konten" : "Einnahme oder Ausgabe")).Click();
if (transfer) page.Find("#transfer-account").Change("2");
page.Find(transfer ? "#transfer-title" : "#entry-title").Input("Testbuchung");
page.Find(transfer ? "#transfer-amount" : "#entry-amount").Input("12,50");
}
private sealed class Fixture : IDisposable
{
public BunitContext Context { get; } = new();
public ControlledEntries Entries { get; }
private readonly SqliteConnection anchor;
public Fixture()
{
var connection = $"Data Source=save-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
anchor = new SqliteConnection(connection);
anchor.Open();
Context.Services.AddInfrastructure(connection);
Context.Services.AddSingleton<IEntryService>(sp => new ControlledEntries(new EntryService(sp.GetRequiredService<IDbContextFactory<FinanceDbContext>>())));
Context.JSInterop.Mode = JSRuntimeMode.Loose;
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
db.Database.EnsureCreated();
db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" });
db.SaveChanges();
Entries = (ControlledEntries)Context.Services.GetRequiredService<IEntryService>();
}
public void Dispose() { Context.Dispose(); anchor.Dispose(); }
}
private sealed class ControlledEntries(IEntryService inner) : IEntryService
{
public int WriteCalls;
public Func<Task> BeforeWrite = () => Task.CompletedTask;
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{ WriteCalls++; await BeforeWrite(); return await inner.CreateEntryAsync(accountId, type, date, title, amount); }
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
{ WriteCalls++; await BeforeWrite(); return await inner.CreateTransferAsync(sourceAccountId, targetAccountId, date, title, amount); }
public Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year);
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly);
public Task<List<int>> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId);
public Task<List<int>> GetAllEntryYearsAsync() => inner.GetAllEntryYearsAsync();
public Task DeleteEntryAsync(int entryId) => inner.DeleteEntryAsync(entryId);
public Task RestoreEntryAsync(int entryId) => inner.RestoreEntryAsync(entryId);
public Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) => inner.UpdateEntryAsync(entryId, date, title, amount);
public Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) => inner.UpdateTransferAsync(entryId, newLinkedAccountId, date, title, amount);
}
}
@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" Version="2.13.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="PdfPig" Version="0.1.16" />
<PackageReference Include="xunit" Version="2.9.3" />
@@ -0,0 +1,19 @@
using Duempelkas.App.Services;
using Xunit;
namespace Duempelkas.Tests;
public class ReportingPeriodTests
{
[Fact]
public void EmptyYearList_PreservesSelectedYear_AndOffersCurrentYear()
{
var period = new ReportingPeriod { Year = 2025 };
Assert.Contains(2025, period.Choices([]));
Assert.Contains(DateTime.Today.Year, period.Choices([]));
Assert.Equal("Jahr: 2025", period.Label);
period.Year = null;
Assert.Equal("Alle Jahre", period.Label);
Assert.Null(period.Year);
}
}
@@ -0,0 +1,65 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class YearNavigationTests
{
[Fact]
public async Task Navigation_PreservesYear_AndOutsideBookingOffersReturn()
{
using var context = new BunitContext();
var connectionString = $"Data Source=year-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
using var anchor = new SqliteConnection(connectionString);
anchor.Open();
context.Services.AddInfrastructure(connectionString);
context.JSInterop.Mode = JSRuntimeMode.Loose;
using (var db = context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext())
{
db.Database.EnsureCreated();
db.Accounts.Add(new() { Name = "Testkasse" });
db.SaveChanges();
await context.Services.GetRequiredService<IEntryService>().CreateEntryAsync(db.Accounts.Single().Id,
EntryType.Income, new(2025, 1, 1), "Vorjahr", 10m);
}
context.Services.GetRequiredService<ReportingPeriod>().Year = 2025;
var page = context.Render<Routes>();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click();
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
page.Find(".account-card").Click();
page.WaitForElement(".summary-section");
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
var dialog = page.FindComponent<AddEntryDialog>();
dialog.Find("input[type=date]").Input("2026-01-02");
dialog.Find("#entry-title").Input("Außerhalb");
dialog.Find("#entry-amount").Input("12.50");
Assert.Contains("außerhalb", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true);
dialog.Find("form").Submit();
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
page.FindAll("button").Single(b => b.TextContent.Contains("Jahr: 2026")).Click();
page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click();
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup));
page.Find(".account-card").Click();
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
}
}