diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index c3c8396..9c5d897 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -67,3 +67,7 @@ Gemeinsame native dialog-Komponente für Erfassung, Bestätigungen, Konten, Name ## U09 – Zeilenaktionen Beschriftetes Aktionsmenü je Buchung, sichtbares Bearbeiten/Löschen und Wiederherstellen als Wort. Zugängliche Namen nennen Nummer und Beschreibung, Trefferbereiche mindestens 44 CSS-Pixel. Auf schmalen Fenstern wechseln Zeilen in beschriftete Karten, damit die Aktionsspalte nicht die Beschreibung verdrängt. Gerenderter Test prüft Zuordnung und Aktion. + +## U10 – Löschen und Rückweg + +Gelöschte Zeilen sind standardmäßig ausgeblendet und gezählt erreichbar; sichtbare Historie erklärt die Nichtberücksichtigung im Kontostand. Bestätigungen nennen Datum, Beschreibung, Betrag, Konto und bei Umbuchungen beide Konten mit gemeinsamer Wirkung. Erfolg bietet gezieltes Rückgängig; Mehrfachauslösung wird gesperrt, bereits veränderter Zustand geprüft. Gerenderter Integrationstest löscht eine Umbuchung, prüft Gegenkonto, Historie und Rückgängig auf beiden Seiten. diff --git a/src/Duempelkas.App/Components/Accounts/EntryRow.razor b/src/Duempelkas.App/Components/Accounts/EntryRow.razor index 24b5848..cc5d398 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryRow.razor +++ b/src/Duempelkas.App/Components/Accounts/EntryRow.razor @@ -3,6 +3,10 @@ @Entry.Date.ToString("dd.MM.yyyy") @Entry.Title + @if (Entry.IsDeleted) + { +

Gelöscht – zählt nicht zum Kontostand

+ } @if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName)) { diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor b/src/Duempelkas.App/Components/Accounts/EntryTable.razor index 8cc0a66..d3e467d 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor +++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor @@ -1,3 +1,7 @@ +@if (Entries.Any(e => e.IsDeleted)) +{ + +}
@@ -10,7 +14,7 @@ - @foreach (var entry in Entries) + @foreach (var entry in VisibleEntries) { } @@ -18,7 +22,7 @@
-@if (!Entries.Any()) +@if (!VisibleEntries.Any()) {
@EmptyMessage
} diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs index 04503b2..1e23664 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs +++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs @@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts; public partial class EntryTable { + private bool showDeleted; + private IEnumerable VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted); [Parameter] public int? HighlightedEntryId { get; set; } [Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden."; #region Parameters diff --git a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor index 3feb27d..00f1025 100644 --- a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor @@ -1,9 +1,9 @@ - +
@Title

@Message

- - + +
diff --git a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs index cfa5f18..edc73cb 100644 --- a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs @@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs; public partial class ConfirmDialog { + [Parameter] public bool Busy { get; set; } #region Parameters [Parameter] @@ -34,7 +35,7 @@ public partial class ConfirmDialog #region Actions - private async Task Confirm() => await OnConfirm.InvokeAsync(); + private async Task Confirm() { if (!Busy) await OnConfirm.InvokeAsync(); } private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); } diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index b9b687c..e28778e 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -133,7 +133,7 @@ @if (confirmDeleteEntryId.HasValue) { Period.Year; set => Period.Year = value; } private List availableYears = []; + private bool entryActionBusy; + private Guid? undoToastId; private int? confirmDeleteEntryId; private string? confirmDeleteEntryTitle; @@ -144,19 +146,27 @@ public partial class AccountDetail private void CancelDeleteConfirm() { + if (entryActionBusy) return; confirmDeleteEntryId = null; confirmDeleteEntryTitle = null; } private async Task HandleConfirmDelete() { - if (confirmDeleteEntryId.HasValue) + if (entryActionBusy || !confirmDeleteEntryId.HasValue) return; + entryActionBusy = true; + try { - await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value); + var id = confirmDeleteEntryId.Value; + await EntryService.DeleteEntryAsync(id); confirmDeleteEntryId = null; - confirmDeleteEntryTitle = null; await LoadAll(); + if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); + undoToastId = Toasts.Show("Buchung gelöscht.", ToastKind.Success, + actionLabel: "Rückgängig", action: () => UndoEntryChange(id, true)); } + catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); } + finally { entryActionBusy = false; } } private void RequestRestoreEntry(int entryId) @@ -167,19 +177,57 @@ public partial class AccountDetail private void CancelRestoreConfirm() { + if (entryActionBusy) return; confirmRestoreEntryId = null; confirmRestoreEntryTitle = null; } private async Task HandleConfirmRestore() { - if (confirmRestoreEntryId.HasValue) + if (entryActionBusy || !confirmRestoreEntryId.HasValue) return; + entryActionBusy = true; + try { - await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value); + var id = confirmRestoreEntryId.Value; + await EntryService.RestoreEntryAsync(id); confirmRestoreEntryId = null; - confirmRestoreEntryTitle = null; await LoadAll(); + if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); + undoToastId = Toasts.Show("Buchung wiederhergestellt.", ToastKind.Success, + actionLabel: "Rückgängig", action: () => UndoEntryChange(id, false)); } + catch (Exception ex) { Toasts.Show("Die Buchung konnte nicht geändert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); } + finally { entryActionBusy = false; } + } + + private async Task UndoEntryChange(int id, bool restore) + { + if (entryActionBusy) return; + entryActionBusy = true; + try + { + var current = (await EntryService.GetEntriesAsync(AccountId, (int?)null)).FirstOrDefault(e => e.Id == id); + if (current == null || current.IsDeleted != restore) + { + Toasts.Show("Die Buchung wurde inzwischen geändert. Bitte die aktuelle Liste prüfen."); + return; + } + if (restore) await EntryService.RestoreEntryAsync(id); else await EntryService.DeleteEntryAsync(id); + if (undoToastId.HasValue) Toasts.Dismiss(undoToastId.Value); + await LoadAll(); + Toasts.Show("Änderung rückgängig gemacht.", ToastKind.Success); + } + finally { entryActionBusy = false; } + } + + private string EntryChangeMessage(int? id, bool restore) + { + var entry = entries?.FirstOrDefault(e => e.Id == id); + if (entry == null) return "Bitte die aktuelle Buchung prüfen."; + var description = $"{entry.Date:dd.MM.yyyy}, {entry.Title}, {FormatAmount(entry.Amount)}, Konto {account?.Name}."; + return description + (entry.IsTransfer + ? $" Diese Umbuchung betrifft {account?.Name} und {entry.LinkedAccountName}. Die Buchungen auf beiden Konten werden gemeinsam {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet")}." + : $" Die Buchung wird {(restore ? "wiederhergestellt und wieder mitgerechnet" : "gelöscht und nicht mehr mitgerechnet") }."); } private void RequestEditEntry(int entryId) diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index 8d068be..a3e4234 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -654,3 +654,7 @@ body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; } .entry-list td::before { content: attr(data-label); display: block; color: var(--color-text-muted); font-size: .85rem; } .entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; } } + +.history-toggle { display: block; padding: .75rem 0; } +.entry-deleted { opacity: 1 !important; } +.deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; } diff --git a/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs b/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs new file mode 100644 index 0000000..9a38372 --- /dev/null +++ b/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs @@ -0,0 +1,32 @@ +using Bunit; +using Duempelkas.App.Components; +using Duempelkas.App.Services; +using Xunit; + +namespace Duempelkas.Tests; + +public class EntryLifecycleUiTests +{ + [Fact] + public async Task DeleteTransfer_ExplainsBothAccounts_HidesRows_AndUndoRestoresBoth() + { + using var f = new LedgerUiFixture(); + var entries = f.Service(); + await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m); + var page = f.Context.Render(); + page.WaitForElement(".account-card").Click(); + page.WaitForElement(".entry-action-menu"); + page.Find("button.btn-outline-danger").Click(); + page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup)); + Assert.Contains("Barkasse", page.Find("dialog").TextContent); + Assert.Contains("Girokonto", page.Find("dialog").TextContent); + page.Find("dialog button.btn-danger").Click(); + page.WaitForAssertion(() => Assert.Empty(page.FindAll("tbody tr"))); + Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.True(e.IsDeleted)); + page.Find(".history-toggle input").Change(true); + Assert.Contains("zählt nicht zum Kontostand", page.Markup); + page.Find(".toast-success .btn").Click(); + page.WaitForAssertion(() => Assert.Single(page.FindAll("tbody tr"))); + Assert.All(await entries.GetEntriesAsync(2, (int?)null), e => Assert.False(e.IsDeleted)); + } +} diff --git a/tests/Duempelkas.Tests/LedgerUiFixture.cs b/tests/Duempelkas.Tests/LedgerUiFixture.cs new file mode 100644 index 0000000..14a317c --- /dev/null +++ b/tests/Duempelkas.Tests/LedgerUiFixture.cs @@ -0,0 +1,33 @@ +using Bunit; +using Duempelkas.Infrastructure; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Duempelkas.Tests; + +internal sealed class LedgerUiFixture : IDisposable +{ + public BunitContext Context { get; } = new(); + private readonly SqliteConnection anchor; + private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-ui-" + Guid.NewGuid().ToString("N")); + public LedgerUiFixture(bool accounts = true) + { + Directory.CreateDirectory(folder); + var connection = $"Data Source=ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + anchor = new SqliteConnection(connection); + anchor.Open(); + Context.Services.AddInfrastructure(connection, folder); + Context.JSInterop.Mode = JSRuntimeMode.Loose; + using var db = Context.Services.GetRequiredService>().CreateDbContext(); + db.Database.EnsureCreated(); + if (accounts) + { + db.Accounts.AddRange(new() { Name = "Barkasse", CarryoverBalance = 100m }, new() { Name = "Girokonto" }); + db.SaveChanges(); + } + } + public T Service() where T : notnull => Context.Services.GetRequiredService(); + public void Dispose() { Context.Dispose(); anchor.Dispose(); Directory.Delete(folder, true); } +}