feat(usability): explain deleted bookings and linked transfer undo (U10)
This commit is contained in:
@@ -67,3 +67,7 @@ Gemeinsame native dialog-Komponente für Erfassung, Bestätigungen, Konten, Name
|
|||||||
## U09 – Zeilenaktionen
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||||
<td data-label="Beschreibung" class="entry-description">
|
<td data-label="Beschreibung" class="entry-description">
|
||||||
@Entry.Title
|
@Entry.Title
|
||||||
|
@if (Entry.IsDeleted)
|
||||||
|
{
|
||||||
|
<p class="deleted-status">Gelöscht – zählt nicht zum Kontostand</p>
|
||||||
|
}
|
||||||
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
||||||
{
|
{
|
||||||
<small class="text-muted ms-1">
|
<small class="text-muted ms-1">
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
@if (Entries.Any(e => e.IsDeleted))
|
||||||
|
{
|
||||||
|
<label class="history-toggle"><input type="checkbox" @bind="showDeleted" /> Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label>
|
||||||
|
}
|
||||||
<div class="table-responsive entry-list">
|
<div class="table-responsive entry-list">
|
||||||
<table class="table table-hover table-sm align-middle">
|
<table class="table table-hover table-sm align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -10,7 +14,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var entry in Entries)
|
@foreach (var entry in VisibleEntries)
|
||||||
{
|
{
|
||||||
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
<EntryRow @key="entry.Id" Highlighted="@(entry.Id == HighlightedEntryId)" Entry="entry" OnDelete="OnDeleteEntry" OnRestore="OnRestoreEntry" OnEdit="OnEditEntry" />
|
||||||
}
|
}
|
||||||
@@ -18,7 +22,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!Entries.Any())
|
@if (!VisibleEntries.Any())
|
||||||
{
|
{
|
||||||
<div class="text-center py-3 text-muted">@EmptyMessage</div>
|
<div class="text-center py-3 text-muted">@EmptyMessage</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace Duempelkas.App.Components.Accounts;
|
|||||||
|
|
||||||
public partial class EntryTable
|
public partial class EntryTable
|
||||||
{
|
{
|
||||||
|
private bool showDeleted;
|
||||||
|
private IEnumerable<EntryDto> VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted);
|
||||||
[Parameter] public int? HighlightedEntryId { get; set; }
|
[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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" >
|
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" Busy="Busy">
|
||||||
<h5>@Title</h5>
|
<h5>@Title</h5>
|
||||||
<p>@Message</p>
|
<p>@Message</p>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button type="button" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
<button type="button" disabled="@Busy" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
<button type="button" disabled="@Busy" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Duempelkas.App.Components.Dialogs;
|
|||||||
|
|
||||||
public partial class ConfirmDialog
|
public partial class ConfirmDialog
|
||||||
{
|
{
|
||||||
|
[Parameter] public bool Busy { get; set; }
|
||||||
#region Parameters
|
#region Parameters
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -34,7 +35,7 @@ public partial class ConfirmDialog
|
|||||||
|
|
||||||
#region Actions
|
#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(); }
|
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
@if (confirmDeleteEntryId.HasValue)
|
@if (confirmDeleteEntryId.HasValue)
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="Buchung löschen"
|
<ConfirmDialog Title="Buchung löschen"
|
||||||
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")"
|
Message="@(EntryChangeMessage(confirmDeleteEntryId, false))" Busy="entryActionBusy"
|
||||||
ConfirmText="Ja, löschen"
|
ConfirmText="Ja, löschen"
|
||||||
CancelText="Nein, abbrechen"
|
CancelText="Nein, abbrechen"
|
||||||
ConfirmButtonClass="btn btn-danger"
|
ConfirmButtonClass="btn btn-danger"
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
@if (confirmRestoreEntryId.HasValue)
|
@if (confirmRestoreEntryId.HasValue)
|
||||||
{
|
{
|
||||||
<ConfirmDialog Title="Buchung wiederherstellen"
|
<ConfirmDialog Title="Buchung wiederherstellen"
|
||||||
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")"
|
Message="@(EntryChangeMessage(confirmRestoreEntryId, true))" Busy="entryActionBusy"
|
||||||
ConfirmText="Ja, wiederherstellen"
|
ConfirmText="Ja, wiederherstellen"
|
||||||
CancelText="Nein, abbrechen"
|
CancelText="Nein, abbrechen"
|
||||||
ConfirmButtonClass="btn btn-warning"
|
ConfirmButtonClass="btn btn-warning"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public partial class AccountDetail
|
|||||||
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
private int? selectedYear { get => Period.Year; set => Period.Year = value; }
|
||||||
private List<int> availableYears = [];
|
private List<int> availableYears = [];
|
||||||
|
|
||||||
|
private bool entryActionBusy;
|
||||||
|
private Guid? undoToastId;
|
||||||
private int? confirmDeleteEntryId;
|
private int? confirmDeleteEntryId;
|
||||||
private string? confirmDeleteEntryTitle;
|
private string? confirmDeleteEntryTitle;
|
||||||
|
|
||||||
@@ -144,19 +146,27 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private void CancelDeleteConfirm()
|
private void CancelDeleteConfirm()
|
||||||
{
|
{
|
||||||
|
if (entryActionBusy) return;
|
||||||
confirmDeleteEntryId = null;
|
confirmDeleteEntryId = null;
|
||||||
confirmDeleteEntryTitle = null;
|
confirmDeleteEntryTitle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleConfirmDelete()
|
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;
|
confirmDeleteEntryId = null;
|
||||||
confirmDeleteEntryTitle = null;
|
|
||||||
await LoadAll();
|
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)
|
private void RequestRestoreEntry(int entryId)
|
||||||
@@ -167,19 +177,57 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private void CancelRestoreConfirm()
|
private void CancelRestoreConfirm()
|
||||||
{
|
{
|
||||||
|
if (entryActionBusy) return;
|
||||||
confirmRestoreEntryId = null;
|
confirmRestoreEntryId = null;
|
||||||
confirmRestoreEntryTitle = null;
|
confirmRestoreEntryTitle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleConfirmRestore()
|
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;
|
confirmRestoreEntryId = null;
|
||||||
confirmRestoreEntryTitle = null;
|
|
||||||
await LoadAll();
|
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)
|
private void RequestEditEntry(int entryId)
|
||||||
|
|||||||
@@ -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 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; }
|
.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; }
|
||||||
|
|||||||
@@ -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<IEntryService>();
|
||||||
|
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Testtransfer", 20m);
|
||||||
|
var page = f.Context.Render<Routes>();
|
||||||
|
page.WaitForElement(".account-card").Click();
|
||||||
|
page.WaitForElement(".entry-action-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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.Infrastructure;
|
||||||
|
using Duempelkas.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
internal sealed class LedgerUiFixture : IDisposable
|
||||||
|
{
|
||||||
|
public BunitContext Context { get; } = new();
|
||||||
|
private readonly SqliteConnection anchor;
|
||||||
|
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-ui-" + Guid.NewGuid().ToString("N"));
|
||||||
|
public LedgerUiFixture(bool accounts = true)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var connection = $"Data Source=ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||||
|
anchor = new SqliteConnection(connection);
|
||||||
|
anchor.Open();
|
||||||
|
Context.Services.AddInfrastructure(connection, folder);
|
||||||
|
Context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
if (accounts)
|
||||||
|
{
|
||||||
|
db.Accounts.AddRange(new() { Name = "Barkasse", CarryoverBalance = 100m }, new() { Name = "Girokonto" });
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public T Service<T>() where T : notnull => Context.Services.GetRequiredService<T>();
|
||||||
|
public void Dispose() { Context.Dispose(); anchor.Dispose(); Directory.Delete(folder, true); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user