feat(usability): confirm saves and guard booking submissions against duplicates (U07)
This commit is contained in:
@@ -39,3 +39,13 @@ Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung
|
|||||||
## U06 – Verständliche Eingabe
|
## 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.
|
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
|
public partial class EntryRow
|
||||||
{
|
{
|
||||||
|
[Parameter] public bool Highlighted { get; set; }
|
||||||
|
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -26,6 +28,7 @@ public partial class EntryRow
|
|||||||
private string GetRowClass()
|
private string GetRowClass()
|
||||||
{
|
{
|
||||||
var classes = new List<string>();
|
var classes = new List<string>();
|
||||||
|
if (Highlighted) classes.Add("entry-recent");
|
||||||
if (Entry.IsTransfer)
|
if (Entry.IsTransfer)
|
||||||
{
|
{
|
||||||
classes.Add("entry-row-transfer");
|
classes.Add("entry-row-transfer");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var entry in Entries)
|
@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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Duempelkas.App.Components.Accounts;
|
|||||||
|
|
||||||
public partial class EntryTable
|
public partial class EntryTable
|
||||||
{
|
{
|
||||||
|
[Parameter] public int? HighlightedEntryId { get; set; }
|
||||||
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
|
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
|
<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>
|
<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">
|
<div class="form-container">
|
||||||
<label class="form-label" for="entry-type">Art</label>
|
<label class="form-label" for="entry-type">Art</label>
|
||||||
@if (EditEntry == null)
|
@if (EditEntry == null)
|
||||||
@@ -47,11 +49,20 @@
|
|||||||
</div>
|
</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">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button class="btn btn-success" @onclick="Save" disabled="@(!CanSave)">
|
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||||
<i class="bi bi-check-lg"></i> Speichern
|
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public partial class AddEntryDialog
|
|||||||
public EntryDto? EditEntry { get; set; }
|
public EntryDto? EditEntry { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<DateTime> OnSave { get; set; }
|
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnCancel { get; set; }
|
public EventCallback OnCancel { get; set; }
|
||||||
@@ -30,6 +30,12 @@ public partial class AddEntryDialog
|
|||||||
[Parameter] public string AccountName { get; set; } = string.Empty;
|
[Parameter] public string AccountName { get; set; } = string.Empty;
|
||||||
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||||
|
|
||||||
|
private bool isSaving;
|
||||||
|
private bool completed;
|
||||||
|
private BookingSaved? committedResult;
|
||||||
|
private string? saveError;
|
||||||
|
private string? saveDetails;
|
||||||
|
|
||||||
#region Fields
|
#region Fields
|
||||||
|
|
||||||
private EntryType entryType = EntryType.Income;
|
private EntryType entryType = EntryType.Income;
|
||||||
@@ -66,20 +72,40 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (!CanSave) return;
|
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)
|
if (EditEntry != null)
|
||||||
{
|
|
||||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||||
}
|
|
||||||
else
|
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(date);
|
private async Task Cancel()
|
||||||
|
{
|
||||||
|
if (!isSaving) await OnCancel.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
|
||||||
|
|
||||||
|
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||||
|
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">Zielkonto</label>
|
<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>
|
<option value="0">— Auswählen —</option>
|
||||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||||
{
|
{
|
||||||
@@ -18,13 +20,13 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="form-label">Datum</label>
|
<label class="form-label">Datum</label>
|
||||||
<input type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
|
<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>
|
<label class="form-label">Bezeichnung</label>
|
||||||
<input type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||||
|
|
||||||
<label class="form-label">Betrag (€)</label>
|
<label class="form-label">Betrag (€)</label>
|
||||||
<div><input type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -36,11 +38,21 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-end gap-2">
|
</fieldset>
|
||||||
<button class="btn btn-success" @onclick="Save"
|
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||||
disabled="@(!CanSave || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
|
@if (saveError != null)
|
||||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
{
|
||||||
|
<div class="alert alert-danger" role="alert">@saveError
|
||||||
|
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public partial class AddTransferDialog
|
|||||||
public EntryDto? EditEntry { get; set; }
|
public EntryDto? EditEntry { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<DateTime> OnSave { get; set; }
|
public EventCallback<BookingSaved> OnSave { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback OnCancel { get; set; }
|
public EventCallback OnCancel { get; set; }
|
||||||
@@ -26,6 +26,12 @@ public partial class AddTransferDialog
|
|||||||
private int? confirmedYear;
|
private int? confirmedYear;
|
||||||
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
|
||||||
|
|
||||||
|
private bool isSaving;
|
||||||
|
private bool completed;
|
||||||
|
private BookingSaved? committedResult;
|
||||||
|
private string? saveError;
|
||||||
|
private string? saveDetails;
|
||||||
|
|
||||||
#region Fields
|
#region Fields
|
||||||
|
|
||||||
private List<AccountSummaryDto> accounts = new();
|
private List<AccountSummaryDto> accounts = new();
|
||||||
@@ -74,20 +80,40 @@ public partial class AddTransferDialog
|
|||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (!CanSave) return;
|
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)
|
if (EditEntry != null)
|
||||||
{
|
|
||||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||||
}
|
|
||||||
else
|
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(date);
|
private async Task Cancel()
|
||||||
|
{
|
||||||
|
if (!isSaving) await OnCancel.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
<OperationNotice Result="fileResult" />
|
<OperationNotice Result="fileResult" />
|
||||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
@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)
|
@if (savedOutsideYear.HasValue)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info" role="status">
|
<div class="alert alert-info" role="status">
|
||||||
@@ -103,7 +109,7 @@
|
|||||||
@if (entries != null)
|
@if (entries != null)
|
||||||
{
|
{
|
||||||
<div class="entries-scroll-wrapper">
|
<div class="entries-scroll-wrapper">
|
||||||
<EntryTable 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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public partial class AccountDetail
|
|||||||
private bool fileBusy;
|
private bool fileBusy;
|
||||||
|
|
||||||
private int? savedOutsideYear;
|
private int? savedOutsideYear;
|
||||||
|
private BookingSaved? lastSaved;
|
||||||
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
|
private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
|
||||||
private EntryDto? editingEntry;
|
private EntryDto? editingEntry;
|
||||||
private EntryDto? editingTransferEntry;
|
private EntryDto? editingTransferEntry;
|
||||||
@@ -105,18 +106,19 @@ public partial class AccountDetail
|
|||||||
await LoadAll();
|
await LoadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleEntryCreated(DateTime savedDate)
|
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||||
{
|
|
||||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
|
|
||||||
showAddEntry = false;
|
|
||||||
await LoadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleTransferCreated(DateTime savedDate)
|
private Task HandleTransferCreated(BookingSaved saved) => HandleBookingSaved(saved);
|
||||||
|
|
||||||
|
private async Task HandleBookingSaved(BookingSaved saved)
|
||||||
{
|
{
|
||||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
|
|
||||||
showAddTransfer = false;
|
|
||||||
await LoadAll();
|
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 Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
|
private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
|
||||||
@@ -180,13 +182,7 @@ public partial class AccountDetail
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleEntryEdited(DateTime savedDate)
|
private Task HandleEntryEdited(BookingSaved saved) => HandleBookingSaved(saved);
|
||||||
{
|
|
||||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
|
|
||||||
editingEntry = null;
|
|
||||||
editingTransferEntry = null;
|
|
||||||
await LoadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleExport()
|
private async Task HandleExport()
|
||||||
{
|
{
|
||||||
@@ -234,7 +230,7 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
|
private static string FormatAmount(decimal amount) => amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||||
|
|
||||||
private string GetFilterLabel() => Period.Label;
|
private string GetFilterLabel() => Period.Label;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public interface IEntryService
|
|||||||
Task<List<int>> GetEntryYearsAsync(int accountId);
|
Task<List<int>> GetEntryYearsAsync(int accountId);
|
||||||
Task<List<int>> GetAllEntryYearsAsync();
|
Task<List<int>> GetAllEntryYearsAsync();
|
||||||
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
||||||
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||||
Task DeleteEntryAsync(int entryId);
|
Task DeleteEntryAsync(int entryId);
|
||||||
Task RestoreEntryAsync(int entryId);
|
Task RestoreEntryAsync(int entryId);
|
||||||
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
|
Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Duempelkas.App.Services.Models;
|
||||||
|
|
||||||
|
public sealed record BookingSaved(int EntryId, DateTime Date, string Kind, decimal Amount, bool Edited);
|
||||||
@@ -46,7 +46,7 @@ class Program
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,3 +602,13 @@ input[type="number"].no-spinner {
|
|||||||
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
|
.form-container p { margin: .35rem 0 0; font-size: .9rem; }
|
||||||
|
|
||||||
.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
|
.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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ public class EntryService : IEntryService
|
|||||||
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
|
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
public async Task<int> CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
BookingInput.Validate(date, title, amount);
|
BookingInput.Validate(date, title, amount);
|
||||||
if (sourceAccountId == targetAccountId)
|
if (sourceAccountId == targetAccountId)
|
||||||
@@ -173,6 +173,7 @@ public class EntryService : IEntryService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
return sourceEntry.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteEntryAsync(int entryId)
|
public async Task DeleteEntryAsync(int entryId)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,11 +48,18 @@ public class YearNavigationTests
|
|||||||
Assert.Contains("außerhalb", dialog.Markup);
|
Assert.Contains("außerhalb", dialog.Markup);
|
||||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||||
dialog.Find("input[type=checkbox]").Change(true);
|
dialog.Find("input[type=checkbox]").Change(true);
|
||||||
dialog.Find("button.btn-success").Click();
|
dialog.Find("form").Submit();
|
||||||
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
|
page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
|
||||||
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
|
Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
|
||||||
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
|
page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
|
||||||
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
|
page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
|
||||||
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
|
Assert.Equal(2026, context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Contains("Jahr: 2026")).Click();
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Trim() == "Alle Buchungen").Click();
|
||||||
|
page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Alle Jahre", page.Markup));
|
||||||
|
page.Find(".account-card").Click();
|
||||||
|
page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
|
||||||
|
Assert.Null(context.Services.GetRequiredService<ReportingPeriod>().Year);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user