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))
+{
+ Gelöschte Buchungen anzeigen (@Entries.Count(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
- @ConfirmText
- @CancelText
+ @ConfirmText
+ @CancelText
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); }
+}