diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md
index ccf09d4..509e9e6 100644
--- a/docs/usability-umsetzung.md
+++ b/docs/usability-umsetzung.md
@@ -89,3 +89,7 @@ U13-Kontrastmessung: Text-, Hilfstext-, Einnahme-, Ausgabe- und Umbuchungsfarben
## U14 – Lokale Hilfe und Einführung
Hilfe steht in der Navigation und wird als Teil der Anwendung offline ausgeliefert. Sie beschreibt die aktuellen Abläufe einschließlich Anfangsbestand, Undo, Suche, Sicherungsumfang und Fehlerauswegen. Überspringbare Einführung mit Vereinsname, erstem Konto/erklärtem Anfangsbestand, echter erster Buchung und Sicherung; Fortschritt wird in den lokalen Einstellungen gespeichert, keine Registrierung und keine automatischen Beispielbuchungen. Der Integrationstest überspringt/öffnet erneut, legt ein selbst benanntes Konto mit Anfangsbestand an und bestätigt die leere Buchungsliste.
+
+## U15 – Suchen und Sortieren
+
+Suche über Beschreibung, Nummer, Datum, Gegenkonto und deutsche Betragsdarstellung; mehrere Suchwörter müssen gemeinsam passen. Auswahl für Datum, vorzeichenrichtigen Betrag und Beschreibung. Trefferzahl und eigene wirksame Treffermengensumme erklären ausdrücklich den unveränderten Kontostand des gewählten Zeitraums. Zurücksetzen zeigt dessen vollständige aktive Liste; Historie bleibt gesondert. Test sucht mit Umlaut und 12,50 aus 400 Einträgen, prüft Nummer, Leerzustand, Reset und unveränderte Originalbeträge.
diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor b/src/Duempelkas.App/Components/Accounts/EntryTable.razor
index d3e467d..7e43935 100644
--- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor
+++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor
@@ -1,3 +1,19 @@
+
+
Buchungen suchen
+
+
Sortierung
+
+ Datum: älteste zuerst Datum: neueste zuerst
+ Betrag: aufsteigend Betrag: absteigend
+ Beschreibung: A–Z
+
+
Filter zurücksetzen
+
+@if (!string.IsNullOrWhiteSpace(searchText))
+{
+ @VisibleEntries.Count Treffer. Summe der wirksamen Treffer: @ResultSum.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) €.
+ Der Kontostand des gewählten Zeitraums oben bleibt unverändert.
+}
@if (Entries.Any(e => e.IsDeleted))
{
Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))
@@ -24,6 +40,6 @@
@if (!VisibleEntries.Any())
{
- @EmptyMessage
+ @(string.IsNullOrWhiteSpace(searchText) ? EmptyMessage : "Keine Treffer. Filter zurücksetzen zeigt wieder die Buchungen des gewählten Zeitraums.")
}
diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
index 1e23664..74fe20e 100644
--- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
+++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
@@ -6,7 +6,11 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable
{
private bool showDeleted;
- private IEnumerable VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted);
+ private string searchText = string.Empty;
+ private string sort = "date-asc";
+ private List VisibleEntries => Duempelkas.App.Services.EntryListQuery.Apply(Entries, searchText, showDeleted, sort);
+ private decimal ResultSum => VisibleEntries.Where(e => !e.IsDeleted).Sum(Duempelkas.App.Services.EntryListQuery.SignedAmount);
+ private void ResetFilters() { searchText = string.Empty; sort = "date-asc"; showDeleted = false; }
[Parameter] public int? HighlightedEntryId { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters
diff --git a/src/Duempelkas.App/Services/EntryListQuery.cs b/src/Duempelkas.App/Services/EntryListQuery.cs
new file mode 100644
index 0000000..65d12ec
--- /dev/null
+++ b/src/Duempelkas.App/Services/EntryListQuery.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+using Duempelkas.App.Services.Models;
+using Duempelkas.Domain.Enums;
+
+namespace Duempelkas.App.Services;
+
+public static class EntryListQuery
+{
+ public static decimal SignedAmount(EntryDto entry) => entry.Type == EntryType.Income ? entry.Amount : -entry.Amount;
+ public static List Apply(IEnumerable entries, string search, bool showDeleted, string sort)
+ {
+ var terms = search.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var de = CultureInfo.GetCultureInfo("de-DE");
+ var matches = entries.Where(e => !e.IsDeleted || showDeleted).Where(e =>
+ {
+ var text = $"{e.Title} {e.DisplayId} {e.Date:dd.MM.yyyy} {e.Amount.ToString("N2", de)} {SignedAmount(e).ToString("0.00", de)} {e.LinkedAccountName}";
+ return terms.All(term => text.Contains(term, StringComparison.OrdinalIgnoreCase));
+ });
+ return (sort switch
+ {
+ "date-desc" => matches.OrderByDescending(e => e.Date).ThenByDescending(e => e.Id),
+ "amount-asc" => matches.OrderBy(SignedAmount).ThenBy(e => e.Id),
+ "amount-desc" => matches.OrderByDescending(SignedAmount).ThenBy(e => e.Id),
+ "title" => matches.OrderBy(e => e.Title, StringComparer.Create(de, true)).ThenBy(e => e.Id),
+ _ => matches.OrderBy(e => e.Date).ThenBy(e => e.Id)
+ }).ToList();
+ }
+}
diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css
index b43640f..ecb3768 100644
--- a/src/Duempelkas.Desktop/wwwroot/css/app.css
+++ b/src/Duempelkas.Desktop/wwwroot/css/app.css
@@ -697,3 +697,8 @@ button,summary { touch-action: manipulation; }
.btn-success:hover { background: #166534; border-color: #166534; color: #fff; }
.btn-info:hover { background: #1e40af; border-color: #1e40af; color: #fff; }
.btn-danger:hover { background: #b91c1c; border-color: #b91c1c; color: #fff; }
+
+.entry-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; padding: .75rem 0; }
+.entry-filters > div { flex: 1 1 14rem; min-width: 0; }
+.entry-filters input,.entry-filters select { width: 100%; box-sizing: border-box; padding: .5rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); }
+.search-summary { padding: .6rem; border-left: 3px solid var(--color-accent); }
diff --git a/tests/Duempelkas.Tests/EntrySearchTests.cs b/tests/Duempelkas.Tests/EntrySearchTests.cs
new file mode 100644
index 0000000..1533aae
--- /dev/null
+++ b/tests/Duempelkas.Tests/EntrySearchTests.cs
@@ -0,0 +1,45 @@
+using Bunit;
+using Duempelkas.App.Components.Accounts;
+using Duempelkas.App.Services;
+using Duempelkas.App.Services.Models;
+using Duempelkas.Domain.Enums;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class EntrySearchTests
+{
+ [Fact]
+ public void Search_FindsKnownBookingInLargeList_AndResetsWithoutChangingAmounts()
+ {
+ using var context = new BunitContext();
+ var entries = Enumerable.Range(1, 400).Select(i => new EntryDto(i, 1, $"2026-{i:D3}", EntryType.Income,
+ new(2026, 1, 1), i == 321 ? "Älteres Jubiläum" : $"Beitrag {i}", i == 321 ? 12.50m : i, false, false, null, null, null)).ToList();
+ var original = entries.Sum(e => e.Amount);
+ var table = context.Render(p => p.Add(x => x.Entries, entries));
+ table.Find("#entry-search").Input("Älteres 12,50");
+ Assert.Single(table.FindAll("tbody tr"));
+ Assert.Contains("12,50", table.Find(".search-summary").TextContent);
+ Assert.Contains("unverändert", table.Find(".search-summary").TextContent);
+ table.Find("#entry-search").Input("nicht vorhanden");
+ Assert.Empty(table.FindAll("tbody tr"));
+ Assert.Contains("Keine Treffer", table.Markup);
+ table.Find(".filter-reset").Click();
+ Assert.Equal(400, table.FindAll("tbody tr").Count);
+ Assert.Equal(original, entries.Sum(e => e.Amount));
+ table.Find("#entry-search").Input("2026-321");
+ Assert.Single(table.FindAll("tbody tr"));
+ }
+
+ [Fact]
+ public void Sort_UsesSignedAmounts_AndDeletedHistoryDoesNotAffectResultSum()
+ {
+ var entries = new List {
+ new(1,1,"2026-001",EntryType.Expense,DateTime.Today,"Porto",20m,false,false,null,null,null),
+ new(2,1,"2026-002",EntryType.Income,DateTime.Today,"Beitrag",10m,false,false,null,null,null),
+ new(3,1,"2026-003",EntryType.Income,DateTime.Today,"Gelöscht",99m,true,false,null,null,null) };
+ Assert.Equal(new[] { 2, 1 }, EntryListQuery.Apply(entries, "", false, "amount-desc").Select(e => e.Id));
+ Assert.Single(EntryListQuery.Apply(entries, "-20,00", false, "date-asc"));
+ Assert.Equal(3, EntryListQuery.Apply(entries, "", true, "date-asc").Count);
+ }
+}