From 76ba79b63e28c3ccea7319468dba8e15fb2d5250 Mon Sep 17 00:00:00 2001 From: troogs Date: Mon, 2 Nov 2026 06:32:32 +0100 Subject: [PATCH] feat(usability): explain booking fields and validate decimal input (U06) --- docs/usability-umsetzung.md | 4 ++ .../Components/Dialogs/AddEntryDialog.razor | 43 ++++++++++------ .../Dialogs/AddEntryDialog.razor.cs | 21 ++++++-- .../Dialogs/AddTransferDialog.razor | 9 ++-- .../Dialogs/AddTransferDialog.razor.cs | 20 +++++--- .../Pages/Accounts/AccountDetail.razor | 4 +- src/Duempelkas.App/Services/BookingInput.cs | 30 +++++++++++ src/Duempelkas.Desktop/wwwroot/css/app.css | 5 ++ .../Services/EntryService.cs | 19 ++++--- tests/Duempelkas.Tests/BookingFormTests.cs | 51 +++++++++++++++++++ tests/Duempelkas.Tests/BookingInputTests.cs | 35 +++++++++++++ tests/Duempelkas.Tests/YearNavigationTests.cs | 6 +-- 12 files changed, 205 insertions(+), 42 deletions(-) create mode 100644 src/Duempelkas.App/Services/BookingInput.cs create mode 100644 tests/Duempelkas.Tests/BookingFormTests.cs create mode 100644 tests/Duempelkas.Tests/BookingInputTests.cs diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index c8bb09d..8ffa232 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -35,3 +35,7 @@ Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück ## U05 – Alltagssprache Aktionsleisten benennen Erfassen, Kontenverschieben, PDF-Speichern und Sicherung ausführlich. Flexible Breiten und Zeilenumbruch ersetzen die feste 75-px-Breite. Rückspielen liegt weiterhin auffindbar unter „Weitere Aktionen“ und erklärt Umfang und Rückfallsicherung. „Kontostand“ und „Änderung im Jahr“ ersetzen Fachbegriffe. Umbuchung erklärt ausdrücklich, dass keine Banküberweisung ausgelöst wird. Anrede in den betroffenen Abläufen: du. Prüfung: 21/21 Debug-Tests; native Sichtprüfung folgt im Abschluss. Ein Nutzertest mit der Zielgruppe ist dadurch nicht ersetzt. + +## 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. diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor index fcb637f..d6b7d19 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor @@ -1,30 +1,45 @@ @inject IEntryService EntryService
-
-
@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")
+ - diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs index e7bfc0f..fa5ebe5 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs @@ -27,12 +27,20 @@ public partial class AddEntryDialog private int? confirmedYear; private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value; + [Parameter] public string AccountName { get; set; } = string.Empty; + private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year)); + #region Fields private EntryType entryType = EntryType.Income; - private DateTime date = DateTime.Today; + private string dateText = DateTime.Today.ToString("yyyy-MM-dd"); + private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText); private string title = string.Empty; - private decimal amount; + private string amountText = string.Empty; + private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _); + private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0; + private bool initialized; + private int? initializedEntryId; #endregion @@ -40,12 +48,15 @@ public partial class AddEntryDialog protected override void OnParametersSet() { + if (initialized && initializedEntryId == EditEntry?.Id) return; + initialized = true; + initializedEntryId = EditEntry?.Id; if (EditEntry != null) { entryType = EditEntry.Type; - date = EditEntry.Date; + dateText = EditEntry.Date.ToString("yyyy-MM-dd"); title = EditEntry.Title; - amount = EditEntry.Amount; + amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); } } @@ -55,7 +66,7 @@ public partial class AddEntryDialog private async Task Save() { - if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return; + if (!CanSave) return; if (EditEntry != null) { await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount); diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor index 8012a6d..32f828e 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor @@ -2,7 +2,7 @@ @inject IAccountService AccountService
-
+
@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")

Erfasst nur die Buchung. Führt keine Banküberweisung aus.

@@ -18,13 +18,14 @@ - + - + - +
+

Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.

@if (OutsideYear) diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs index 6e1541c..dfb4989 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs @@ -30,15 +30,20 @@ public partial class AddTransferDialog private List accounts = new(); private int targetAccountId; - private DateTime date = DateTime.Today; + private string dateText = DateTime.Today.ToString("yyyy-MM-dd"); + private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText); private string title = string.Empty; - private decimal amount; + private string amountText = string.Empty; + private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _); + private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0; + private bool initialized; + private int? initializedEntryId; #endregion #region Properties - private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0; + private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year)); #endregion @@ -51,12 +56,15 @@ public partial class AddTransferDialog accounts = await AccountService.GetAllAccountsAsync(); } + if (initialized && initializedEntryId == EditEntry?.Id) return; + initialized = true; + initializedEntryId = EditEntry?.Id; if (EditEntry != null) { targetAccountId = EditEntry.LinkedAccountId ?? 0; - date = EditEntry.Date; + dateText = EditEntry.Date.ToString("yyyy-MM-dd"); title = EditEntry.Title; - amount = EditEntry.Amount; + amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); } } @@ -66,7 +74,7 @@ public partial class AddTransferDialog private async Task Save() { - if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return; + if (!CanSave) return; if (EditEntry != null) { await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount); diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 002e83a..34bcd11 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -111,7 +111,7 @@ @if (showAddEntry) { - } @@ -175,7 +175,7 @@ @if (editingEntry != null) { - diff --git a/src/Duempelkas.App/Services/BookingInput.cs b/src/Duempelkas.App/Services/BookingInput.cs new file mode 100644 index 0000000..d3c7780 --- /dev/null +++ b/src/Duempelkas.App/Services/BookingInput.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Duempelkas.App.Services; + +public static partial class BookingInput +{ + [GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)] + private static partial Regex AmountPattern(); + + public static bool TryAmount(string text, out decimal amount) + { + amount = 0; + var plain = text.Trim(); + return AmountPattern().IsMatch(plain) + && decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount) + && amount > 0; + } + + public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default; + + public static void Validate(DateTime date, string title, decimal amount) + { + if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben."); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben."); + if (amount <= 0 || decimal.Round(amount, 2) != amount) + throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben."); + } +} diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index 5971ce9..caae055 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -597,3 +597,8 @@ input[type="number"].no-spinner { .more-actions { align-self: center; } .more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; } + +.form-container > div { min-width: 0; } +.form-container p { margin: .35rem 0 0; font-size: .9rem; } + +.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; } diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs index f7d9068..7c8eb6c 100644 --- a/src/Duempelkas.Infrastructure/Services/EntryService.cs +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -99,6 +99,8 @@ public class EntryService : IEntryService public async Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) { + if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart."); + BookingInput.Validate(date, title, amount); using var lease = await DatabaseLease.AcquireAsync(_location); await using var db = await _dbFactory.CreateDbContextAsync(); @@ -110,7 +112,7 @@ public class EntryService : IEntryService DisplayId = displayId, Type = type, Date = date, - Title = title, + Title = title.Trim(), Amount = amount }; db.Entries.Add(entry); @@ -121,6 +123,7 @@ public class EntryService : IEntryService public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) { + BookingInput.Validate(date, title, amount); if (sourceAccountId == targetAccountId) throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich."); @@ -136,7 +139,7 @@ public class EntryService : IEntryService DisplayId = transferDisplayId, Type = EntryType.Expense, Date = date, - Title = title, + Title = title.Trim(), Amount = amount }; @@ -149,7 +152,7 @@ public class EntryService : IEntryService DisplayId = transferDisplayId, Type = EntryType.Income, Date = date, - Title = title, + Title = title.Trim(), Amount = amount }; @@ -220,6 +223,7 @@ public class EntryService : IEntryService public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) { + BookingInput.Validate(date, title, amount); using var lease = await DatabaseLease.AcquireAsync(_location); await using var db = await _dbFactory.CreateDbContextAsync(); @@ -230,7 +234,7 @@ public class EntryService : IEntryService .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); entry.Date = date; - entry.Title = title; + entry.Title = title.Trim(); entry.Amount = amount; if (link != null) @@ -240,7 +244,7 @@ public class EntryService : IEntryService if (otherEntry != null) { otherEntry.Date = date; - otherEntry.Title = title; + otherEntry.Title = title.Trim(); otherEntry.Amount = amount; } } @@ -250,6 +254,7 @@ public class EntryService : IEntryService public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) { + BookingInput.Validate(date, title, amount); using var lease = await DatabaseLease.AcquireAsync(_location); await using var db = await _dbFactory.CreateDbContextAsync(); @@ -266,10 +271,10 @@ public class EntryService : IEntryService // Update date, title, amount on both sides entry.Date = date; - entry.Title = title; + entry.Title = title.Trim(); entry.Amount = amount; otherEntry.Date = date; - otherEntry.Title = title; + otherEntry.Title = title.Trim(); otherEntry.Amount = amount; // If the linked account has changed, move the other entry to the new account diff --git a/tests/Duempelkas.Tests/BookingFormTests.cs b/tests/Duempelkas.Tests/BookingFormTests.cs new file mode 100644 index 0000000..af09f31 --- /dev/null +++ b/tests/Duempelkas.Tests/BookingFormTests.cs @@ -0,0 +1,51 @@ +using Bunit; +using Duempelkas.App.Components.Dialogs; +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Duempelkas.Tests; + +public class BookingFormTests +{ + [Fact] + public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput() + { + using var context = new BunitContext(); + context.Services.AddSingleton(new EntryService(null!)); + var dialog = context.Render(p => p.Add(x => x.AccountName, "Barkasse")); + Assert.Contains("Barkasse", dialog.Find("h5").TextContent); + Assert.Contains("Bitte beschreiben", dialog.Markup); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + dialog.Find("#entry-title").Input("Porto"); + dialog.Find("#entry-amount").Input("12,501"); + Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value")); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + dialog.Find("#entry-amount").Input("-12,50"); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + dialog.Find("#entry-amount").Input("12,50"); + Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); + dialog.Find("#entry-date").Input(""); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + Assert.Contains("Bitte ein gültiges Datum", dialog.Markup); + dialog.Find("#entry-date").Input("2026-01-02"); + Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); + } + + [Fact] + public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput() + { + using var context = new BunitContext(); + context.Services.AddSingleton(new EntryService(null!)); + var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null); + var dialog = context.Render(p => p.Add(x => x.EditEntry, entry)); + Assert.Contains("Ausgabe", dialog.Markup); + Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup); + dialog.Find("#entry-title").Input("Korrigierter Text"); + dialog.Render(p => p.Add(x => x.EditEntry, entry)); + Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value")); + } +} diff --git a/tests/Duempelkas.Tests/BookingInputTests.cs b/tests/Duempelkas.Tests/BookingInputTests.cs new file mode 100644 index 0000000..3e13403 --- /dev/null +++ b/tests/Duempelkas.Tests/BookingInputTests.cs @@ -0,0 +1,35 @@ +using Duempelkas.App.Services; +using Xunit; + +namespace Duempelkas.Tests; + +public class BookingInputTests +{ + [Theory] + [InlineData("12,50", true)] + [InlineData("12.50", true)] + [InlineData(" 12,50 ", true)] + [InlineData("12,500", false)] + [InlineData("-12,50", false)] + [InlineData("1.234,56", false)] + [InlineData("1,234.56", false)] + [InlineData("12,50 €", false)] + [InlineData("0", false)] + [InlineData("", false)] + [InlineData("9999999999999999999999999999999999999", false)] + public void DecimalInput_HasExplicitRules(string text, bool valid) + { + Assert.Equal(valid, BookingInput.TryAmount(text, out var amount)); + if (valid) Assert.Equal(12.50m, amount); + } + + [Fact] + public void ServiceValidation_RejectsInvalidData() + { + Assert.Throws(() => BookingInput.Validate(default, "Test", 1m)); + Assert.Throws(() => BookingInput.Validate(DateTime.Today, " ", 1m)); + Assert.Throws(() => BookingInput.Validate(DateTime.Today, "Test", -1m)); + Assert.Throws(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m)); + Assert.Equal(default, BookingInput.Date("2026-02-30")); + } +} diff --git a/tests/Duempelkas.Tests/YearNavigationTests.cs b/tests/Duempelkas.Tests/YearNavigationTests.cs index ad13731..bea81f8 100644 --- a/tests/Duempelkas.Tests/YearNavigationTests.cs +++ b/tests/Duempelkas.Tests/YearNavigationTests.cs @@ -42,9 +42,9 @@ public class YearNavigationTests page.WaitForElement(".summary-section"); page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click(); var dialog = page.FindComponent(); - dialog.Find("input[type=date]").Change("2026-01-02"); - dialog.Find("input[type=text]").Change("Außerhalb"); - dialog.Find("input[type=number]").Change("12.50"); + dialog.Find("input[type=date]").Input("2026-01-02"); + dialog.Find("#entry-title").Input("Außerhalb"); + dialog.Find("#entry-amount").Input("12.50"); Assert.Contains("außerhalb", dialog.Markup); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); dialog.Find("input[type=checkbox]").Change(true);