Usability U01–U16: sichere Abläufe und Bedienkorrekturen #1
@@ -71,3 +71,7 @@ Beschriftetes Aktionsmenü je Buchung, sichtbares Bearbeiten/Löschen und Wieder
|
||||
## 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.
|
||||
|
||||
## U11 – Umbuchungsrichtung
|
||||
|
||||
Beide Seiten zeigen dieselbe Richtung Von → Nach und erklären das feste aktuelle sowie änderbare Gegenkonto. Vorschau zeigt Minus/Plus und unveränderte Vereinssumme; nur ein Konto erklärt die fehlende Voraussetzung. Der Dienst weist beim Bearbeiten gleiche Konten und fehlende Gegenkonten vor jeder Änderung ab. Tests öffnen beide Seiten, ändern die Quelle einer empfangenen Umbuchung und prüfen den unveränderten Bestand nach unzulässiger Änderung.
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
<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>
|
||||
<p id="transfer-direction"><strong>Von: @FromName → Nach: @ToName</strong></p>
|
||||
<p>@CurrentAccountName bleibt fest. @(IsIncoming ? "Das abgebende Konto ist änderbar." : "Das empfangende Konto ist änderbar.")</p>
|
||||
@if (accounts.Count < 2)
|
||||
{
|
||||
<p role="status">Für eine Umbuchung brauchst du ein zweites Konto. Lege es in der Kontenübersicht an.</p>
|
||||
}
|
||||
@if (ValidAmount && targetAccountId > 0)
|
||||
{
|
||||
<p role="status">@FromName −@Money(amount), @ToName +@Money(amount); Gesamtsumme unverändert.</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" for="transfer-account">Zielkonto</label>
|
||||
<label class="form-label" for="transfer-account">@(IsIncoming ? "Von Konto (änderbar)" : "Nach Konto (änderbar)")</label>
|
||||
<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))
|
||||
|
||||
@@ -52,6 +52,13 @@ public partial class AddTransferDialog
|
||||
|
||||
#region Properties
|
||||
|
||||
private bool IsIncoming => EditEntry?.Type == Duempelkas.Domain.Enums.EntryType.Income;
|
||||
private string CurrentAccountName => accounts.FirstOrDefault(a => a.Id == SourceAccountId)?.Name ?? "Aktuelles Konto";
|
||||
private string LinkedAccountName => accounts.FirstOrDefault(a => a.Id == targetAccountId)?.Name ?? "Konto auswählen";
|
||||
private string FromName => IsIncoming ? LinkedAccountName : CurrentAccountName;
|
||||
private string ToName => IsIncoming ? CurrentAccountName : LinkedAccountName;
|
||||
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -270,6 +270,11 @@ public class EntryService : IEntryService
|
||||
var otherEntry = await db.Entries.FindAsync(otherEntryId)
|
||||
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
|
||||
|
||||
if (newLinkedAccountId == entry.AccountId)
|
||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||
if (!await db.Accounts.AnyAsync(a => a.Id == newLinkedAccountId))
|
||||
throw new InvalidOperationException("Das gewählte Konto ist nicht mehr vorhanden.");
|
||||
|
||||
// Update date, title, amount on both sides
|
||||
entry.Date = date;
|
||||
entry.Title = title.Trim();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components.Dialogs;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class TransferDirectionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task BothSides_ShowSameDirection(int accountId)
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var entry = Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||
var dialog = f.Context.Render<AddTransferDialog>(p => p.Add(x => x.SourceAccountId, accountId).Add(x => x.EditEntry, entry));
|
||||
dialog.WaitForAssertion(() => Assert.Contains("Von: Barkasse → Nach: Girokonto", dialog.Find("#transfer-direction").TextContent));
|
||||
Assert.Contains("Gesamtsumme unverändert", dialog.Markup);
|
||||
Assert.Contains(accountId == 2 ? "Von Konto" : "Nach Konto", dialog.Find("label[for=transfer-account]").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncomingEdit_MovesSource_AndRejectsSameAccountWithoutChanges()
|
||||
{
|
||||
using var f = new LedgerUiFixture();
|
||||
var entries = f.Service<IEntryService>();
|
||||
var third = await f.Service<IAccountService>().CreateAccountAsync("Sparkonto");
|
||||
await entries.CreateTransferAsync(1, 2, DateTime.Today, "Zur Bank", 20m);
|
||||
var incoming = Assert.Single(await entries.GetEntriesAsync(2, (int?)null));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => entries.UpdateTransferAsync(incoming.Id, 2, DateTime.Today, "Unzulässig", 99m));
|
||||
Assert.Equal(20m, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Amount);
|
||||
await entries.UpdateTransferAsync(incoming.Id, third.Id, DateTime.Today, "Andere Quelle", 25m);
|
||||
Assert.Empty(await entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Equal(EntryType.Expense, Assert.Single(await entries.GetEntriesAsync(third.Id, (int?)null)).Type);
|
||||
Assert.Equal(EntryType.Income, Assert.Single(await entries.GetEntriesAsync(2, (int?)null)).Type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user