diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 2a0feca..5fd8126 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -107,3 +107,11 @@ Zusätzliche Headless-Chromium-Prüfung der tatsächlich gerenderten Konto-/Buch Computer Use wurde nach der Unterbrechung fortgesetzt. U08: semantische Kontolinks, sichtbarer Eingabefokus, modaler Accessibility-Baum ohne Hintergrund und Escape-Schließen geprüft. Nach behobener Fensterzuordnung: finale Anwendung mit lokalen Bootstrap-Ressourcen gestartet; beschriftetes Zeilenmenü geöffnet und empfangene Umbuchung mit korrekter Von→Nach-Richtung, festem/änderbarem Konto sowie Minus/Plus-Vorschau gelesen. Sämtliche Tätigkeiten fanden im künstlichen Debug-Datenordner statt. Kein tatsächlicher Bankvorgang und keine Änderung am Vereinsbestand. Native vollständige DPI-/Hilfstechnologie-/Zielgruppenabnahme bleibt offen. Abschlusskorrekturen aus der nativen Prüfung: Betragswert im Korrekturdialog und erklärter Leerzustand werden als Werte statt als Parametertexte gebunden; Regressionen prüfen beide Ausgangsanzeigen. Rückgängig aktualisiert auch den sichtbaren Löschstatus und verwendet nach Navigation weiterhin das ursprüngliche Konto. Eine zweite Escape-Anforderung im Verlustschutz rendert den Wechsel zurück zur Bearbeitung. Finale Prüfung: **58/58 Debug- und 58/58 Release-Tests**, Desktop Debug/Release ohne Warnungen/Fehler; alle 16 Offline-Layoutkombinationen erneut bestanden. Die native Prüfung der Bestandskorrektur bestätigte Konto/Jahr, Formel, Vorher/Nachher und Warnung für alle Jahre; es wurde keine Korrektur gespeichert. Der anschließend korrigierte Zahlenwert ist zusätzlich automatisiert geprüft. + +## Weitere Bedienkorrekturen nach manueller Analyse + +Zusätzliche Nachkommastellen und ungültige Einfügungen werden weiterhin vollständig abgewiesen, jetzt ohne Warnmeldung. Die Historie ist als beschrifteter Switch mit vertikalem Abstand erreichbar. Aktive Buchungen haben direkte Stift-/Papierkorbbuttons mit mindestens 44 × 44 Pixeln, Title und buchungsbezogener zugänglicher Beschriftung. + +Der Jahreswechselhinweis ist eine Warnbox mit Symbol, Datum, angezeigtem und tatsächlichem Buchungsjahr. Die Prüfung erfolgt beim Verlassen des Datumsfelds, damit der native Kalender beim Blättern geöffnet bleibt. Vor dem Speichern wird auch ein noch nicht verlassenes Datumsfeld übernommen und validiert; der Verlustschutz berücksichtigt solche Eingaben bereits während der Bearbeitung. Das gilt für Einnahmen/Ausgaben und Umbuchungen. + +Validierung: 60/60 Tests in Debug und Release; Desktop Debug/Release ohne Warnungen oder Fehler. Regressionen prüfen stille Betragsabweisung, direkte Aktionen, Datumskorrektur, verzögerte Prüfung, externe Datumswerte, Verlustschutz und Submit ohne vorherigen Fokuswechsel. Alle 16 Offline-Layoutkombinationen erneut bestanden. Native Computer-Use-Prüfung mit künstlichem Bestand: Switch blendet gelöschte Zeilen ein, direkter Stift öffnet Bearbeiten, dritte Nachkommastelle bleibt ohne Meldung blockiert. Im Erfassen-Dialog bleibt der Kalender beim Blättern von Dezember 2026 nach Januar 2027 geöffnet; nach Abschluss und Wechsel zur Beschreibung erscheint die Warnbox. Keine Testbuchung gespeichert und keine Vereinsdaten verändert. diff --git a/src/Duempelkas.App/Components/Accounts/EntryRow.razor b/src/Duempelkas.App/Components/Accounts/EntryRow.razor index 70438a8..200b591 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryRow.razor +++ b/src/Duempelkas.App/Components/Accounts/EntryRow.razor @@ -26,15 +26,14 @@ } else { -
- Aktionen - - -
+ } diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor b/src/Duempelkas.App/Components/Accounts/EntryTable.razor index 7e43935..128bffd 100644 --- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor +++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor @@ -16,7 +16,10 @@ } @if (Entries.Any(e => e.IsDeleted)) { - +
+ + +
}
diff --git a/src/Duempelkas.App/Components/AmountInput.razor b/src/Duempelkas.App/Components/AmountInput.razor index 0b454df..c1ee307 100644 --- a/src/Duempelkas.App/Components/AmountInput.razor +++ b/src/Duempelkas.App/Components/AmountInput.razor @@ -1,8 +1,6 @@ -@rejection + @bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled" /> @code { [Parameter] public string Id { get; set; } = "amount"; @@ -10,15 +8,12 @@ [Parameter] public EventCallback ValueChanged { get; set; } [Parameter] public bool Disabled { get; set; } [Parameter] public bool AllowNegative { get; set; } - private string? rejection; private async Task SetValue(string value) { if (!BookingInput.IsAmountDraft(value, AllowNegative)) { - rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben."; return; } - rejection = null; await ValueChanged.InvokeAsync(value); } } diff --git a/src/Duempelkas.App/Components/BookingDateInput.razor b/src/Duempelkas.App/Components/BookingDateInput.razor new file mode 100644 index 0000000..95059b4 --- /dev/null +++ b/src/Duempelkas.App/Components/BookingDateInput.razor @@ -0,0 +1,33 @@ + + +@code { + [Parameter] public string Id { get; set; } = "booking-date"; + [Parameter] public string Value { get; set; } = string.Empty; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public EventCallback PendingChangesChanged { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } + private string? lastValue; + private string renderedValue = string.Empty; + private string draft = string.Empty; + + protected override void OnParametersSet() + { + if (Value != lastValue) draft = renderedValue = Value; + lastValue = Value; + } + + private async Task Input(ChangeEventArgs args) + { + draft = args.Value?.ToString() ?? string.Empty; + await PendingChangesChanged.InvokeAsync(draft != Value); + } + + public async Task CommitAsync() + { + // Native calendars close when their surrounding dialog layout changes. Validate only + // after leaving the date field, so year warnings never interrupt calendar navigation. + lastValue = draft; + await ValueChanged.InvokeAsync(lastValue); + await PendingChangesChanged.InvokeAsync(false); + } +} diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor index afede33..6b96f3c 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor @@ -1,7 +1,7 @@ @inject ToastService Toasts @inject IEntryService EntryService - +
@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName
@@ -24,7 +24,7 @@
- + @if (date == default) {

Bitte ein gültiges Datum eingeben.

}
@@ -43,9 +43,12 @@ @if (OutsideYear && date != default) { -
- Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. +
+ +
Das Buchungsdatum liegt in einem anderen Jahr. +

Du erfasst die Buchung am @date.ToString("dd.MM.yyyy") und damit außerhalb des angezeigten Jahres @ContextYear. Sie wird im Jahr @date.Year gespeichert und erscheint erst, wenn du dieses Jahr oder „Alle Buchungen“ auswählst.

+
} @@ -59,4 +62,4 @@ -@code { private ModalDialog? modal; } +@code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; } diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs index b4fc99d..c74ca5f 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs @@ -76,6 +76,8 @@ public partial class AddEntryDialog private async Task Save() { + if (isSaving || completed) return; + if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync(); if (isSaving || completed || (committedResult == null && !CanSave)) return; var busyToast = Toasts.Show("Wird gespeichert …", persistent: true); isSaving = true; diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor index 11cda45..2c2b161 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor @@ -2,7 +2,7 @@ @inject IEntryService EntryService @inject IAccountService AccountService - +
@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")

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

@@ -30,7 +30,7 @@ - + @@ -40,11 +40,14 @@

Betrag ohne Minuszeichen.

- @if (OutsideYear) + @if (OutsideYear && date != default) { -
- Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt. +
+ +
Das Buchungsdatum liegt in einem anderen Jahr. +

Du erfasst die Buchung am @date.ToString("dd.MM.yyyy") und damit außerhalb des angezeigten Jahres @ContextYear. Sie wird im Jahr @date.Year gespeichert und erscheint erst, wenn du dieses Jahr oder „Alle Buchungen“ auswählst.

+
} @@ -58,4 +61,4 @@ -@code { private ModalDialog? modal; } +@code { private ModalDialog? modal; private BookingDateInput? bookingDate; private bool pendingDateChanges; } diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs index 672b765..fc6415c 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs @@ -91,6 +91,8 @@ public partial class AddTransferDialog private async Task Save() { + if (isSaving || completed) return; + if (committedResult == null && bookingDate != null) await bookingDate.CommitAsync(); if (isSaving || completed || (committedResult == null && !CanSave)) return; var busyToast = Toasts.Show("Wird gespeichert …", persistent: true); isSaving = true; diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index efa90e8..98ec08f 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -629,7 +629,6 @@ fieldset { min-width: 0; } .toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.5rem; } @keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } } -.input-rejection:empty { display: none; } .app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; } .app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); } @@ -642,9 +641,8 @@ body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; } .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; } -.entry-actions button, .entry-action-menu summary { min-height: 44px; padding: .6rem; } -.entry-action-menu summary { cursor: pointer; border: 1px solid var(--color-border); border-radius: .4rem; } -.entry-action-menu button { display: block; width: 100%; margin-top: .5rem; } +.entry-actions button { min-height: 44px; min-width: 44px; padding: .6rem; } +.entry-action-buttons { display: flex; gap: .5rem; } .entry-description { overflow-wrap: anywhere; } @media (max-width: 900px) { .entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } @@ -655,7 +653,12 @@ body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; } .entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; } } -.history-toggle { display: block; padding: .75rem 0; } +.history-toggle { margin-block: 1rem; min-height: 2rem; display: flex; align-items: center; gap: .75rem; } +.history-toggle .form-check-input { flex-shrink: 0; margin-top: 0; } +.booking-year-warning { display: flex; gap: .75rem; align-items: flex-start; } +.booking-year-warning > i { font-size: 1.4rem; } +.booking-year-warning label { display: block; margin-top: .75rem; } +dialog.app-modal { scrollbar-gutter: stable; } .entry-deleted { opacity: 1 !important; } .deleted-status { text-decoration: none; color: var(--color-text-muted); margin: .3rem 0; } diff --git a/src/Duempelkas.Desktop/wwwroot/js/app.js b/src/Duempelkas.Desktop/wwwroot/js/app.js index c856b08..6b035f2 100644 --- a/src/Duempelkas.Desktop/wwwroot/js/app.js +++ b/src/Duempelkas.Desktop/wwwroot/js/app.js @@ -5,10 +5,7 @@ } function accept(input, text) { const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd); - const ok = allowed(input, next); - const hint = document.getElementById(input.id + '-rejected'); - if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.'; - return ok; + return allowed(input, next); } document.addEventListener('beforeinput', e => { if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault(); diff --git a/tests/Duempelkas.Tests/BookingDateInputTests.cs b/tests/Duempelkas.Tests/BookingDateInputTests.cs new file mode 100644 index 0000000..ed0788a --- /dev/null +++ b/tests/Duempelkas.Tests/BookingDateInputTests.cs @@ -0,0 +1,26 @@ +using Bunit; +using Duempelkas.App.Components; +using Xunit; + +namespace Duempelkas.Tests; + +public class BookingDateInputTests +{ + [Fact] + public void DateIsValidatedOnLeavingField_WithoutInterruptingNativeCalendar() + { + using var context = new BunitContext(); + var value = "2025-12-31"; + var field = context.Render(p => p + .Add(x => x.Value, value).Add(x => x.ValueChanged, next => value = next)); + field.Find("input").Input("2026-01-01"); + Assert.Equal("2025-12-31", value); + field.Find("input").Blur(); + Assert.Equal("2026-01-01", value); + field.Render(p => p.Add(x => x.Value, value)); + // The browser owns the edited value; avoid resetting its calendar by writing it back. + Assert.Equal("2025-12-31", field.Find("input").GetAttribute("value")); + field.Render(p => p.Add(x => x.Value, "2027-02-03")); + Assert.Equal("2027-02-03", field.Find("input").GetAttribute("value")); + } +} diff --git a/tests/Duempelkas.Tests/BookingFormTests.cs b/tests/Duempelkas.Tests/BookingFormTests.cs index c61098b..9693002 100644 --- a/tests/Duempelkas.Tests/BookingFormTests.cs +++ b/tests/Duempelkas.Tests/BookingFormTests.cs @@ -11,6 +11,26 @@ namespace Duempelkas.Tests; public class BookingFormTests { + [Fact] + public async Task PendingDate_IsProtectedOnCancel_AndValidatedBeforeSubmit() + { + using var f = new LedgerUiFixture(); + var dialog = f.Context.Render(p => p.Add(x => x.AccountId, 1).Add(x => x.ContextYear, DateTime.Today.Year)); + dialog.Find("#entry-date").Input($"{DateTime.Today.Year + 1}-01-02"); + Assert.Empty(dialog.FindAll(".booking-year-warning")); + var modal = dialog.FindComponent(); + await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync()); + Assert.NotEmpty(dialog.FindAll("[role=alertdialog]")); + dialog.Find("[data-discard-continue]").Click(); + dialog.Find("#entry-title").Input("Pending date"); + dialog.Find("#entry-amount").Input("12,50"); + // A keyboard submit can happen without the date field losing focus first. + dialog.Find("form").Submit(); + Assert.NotEmpty(dialog.FindAll(".booking-year-warning")); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + Assert.Empty(await f.Service().GetEntriesAsync(1, (int?)null)); + } + [Fact] public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput() { @@ -31,9 +51,11 @@ public class BookingFormTests dialog.Find("#entry-amount").Input("12,50"); Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); dialog.Find("#entry-date").Input(""); + dialog.Find("#entry-date").Blur(); 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"); + dialog.Find("#entry-date").Blur(); Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled")); } diff --git a/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs b/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs index 9e14782..d4fb67e 100644 --- a/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs +++ b/tests/Duempelkas.Tests/EntryLifecycleUiTests.cs @@ -15,7 +15,7 @@ public class EntryLifecycleUiTests 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.WaitForElement(".entry-action-buttons"); page.Find("button.btn-outline-danger").Click(); page.WaitForAssertion(() => Assert.Contains("beiden Konten", page.Markup)); Assert.Contains("Barkasse", page.Find("dialog").TextContent); diff --git a/tests/Duempelkas.Tests/EntryPresentationTests.cs b/tests/Duempelkas.Tests/EntryPresentationTests.cs index 0786e57..c41e411 100644 --- a/tests/Duempelkas.Tests/EntryPresentationTests.cs +++ b/tests/Duempelkas.Tests/EntryPresentationTests.cs @@ -18,14 +18,17 @@ public class EntryPresentationTests Assert.DoesNotContain("emptyEntriesMessage", page.Markup); } [Fact] - public void Actions_HaveVisibleTextAndBookingSpecificNames() + public void Actions_AreDirectIconButtonsWithTitlesAndBookingSpecificNames() { using var context = new BunitContext(); var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null); var edited = 0; var table = context.Render(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id)); - Assert.Contains("2026-001 Porto", table.Find("summary").GetAttribute("aria-label")); - var edit = table.FindAll("button").Single(b => b.TextContent.Contains("Bearbeiten")); + Assert.Empty(table.FindAll("summary")); + var edit = table.Find("button[title='Buchung bearbeiten']"); + Assert.Contains("2026-001 Porto", edit.GetAttribute("aria-label")); + Assert.NotNull(edit.QuerySelector(".bi-pencil[aria-hidden=true]")); + Assert.NotNull(table.Find("button.btn-outline-danger .bi-trash")); Assert.Contains("Porto", edit.GetAttribute("aria-label")); edit.Click(); Assert.Equal(1, edited); diff --git a/tests/Duempelkas.Tests/ToastTests.cs b/tests/Duempelkas.Tests/ToastTests.cs index f0c3aaf..457fb26 100644 --- a/tests/Duempelkas.Tests/ToastTests.cs +++ b/tests/Duempelkas.Tests/ToastTests.cs @@ -36,7 +36,7 @@ public class ToastTests var control = context.Render(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value)); control.Find("input").Input("12,501"); Assert.Equal("12,50", amount); - Assert.Contains("nicht übernommen", control.Markup); + Assert.Empty(control.FindAll("[role=status]")); control.Find("input").Input("12.25"); Assert.Equal("12.25", amount); } diff --git a/tests/Duempelkas.Tests/YearNavigationTests.cs b/tests/Duempelkas.Tests/YearNavigationTests.cs index 1f1df70..7c71f85 100644 --- a/tests/Duempelkas.Tests/YearNavigationTests.cs +++ b/tests/Duempelkas.Tests/YearNavigationTests.cs @@ -43,9 +43,11 @@ public class YearNavigationTests page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click(); var dialog = page.FindComponent(); dialog.Find("input[type=date]").Input("2026-01-02"); + dialog.Find("input[type=date]").Blur(); dialog.Find("#entry-title").Input("Außerhalb"); dialog.Find("#entry-amount").Input("12.50"); Assert.Contains("außerhalb", dialog.Markup); + Assert.NotNull(dialog.Find(".booking-year-warning[role=status] .bi-exclamation-triangle-fill")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); dialog.Find("input[type=checkbox]").Change(true); dialog.Find("form").Submit();