feat(entries): search and sort bookings with explicit result totals (U15)
This commit is contained in:
@@ -89,3 +89,7 @@ U13-Kontrastmessung: Text-, Hilfstext-, Einnahme-, Ausgabe- und Umbuchungsfarben
|
|||||||
## U14 – Lokale Hilfe und Einführung
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
<div class="entry-filters">
|
||||||
|
<div><label for="entry-search">Buchungen suchen</label>
|
||||||
|
<input id="entry-search" type="search" class="form-control" @bind="searchText" @bind:event="oninput" placeholder="Beschreibung, Nummer, Datum oder Betrag" /></div>
|
||||||
|
<div><label for="entry-sort">Sortierung</label>
|
||||||
|
<select id="entry-sort" @bind="sort">
|
||||||
|
<option value="date-asc">Datum: älteste zuerst</option><option value="date-desc">Datum: neueste zuerst</option>
|
||||||
|
<option value="amount-asc">Betrag: aufsteigend</option><option value="amount-desc">Betrag: absteigend</option>
|
||||||
|
<option value="title">Beschreibung: A–Z</option>
|
||||||
|
</select></div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary filter-reset" @onclick="ResetFilters">Filter zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(searchText))
|
||||||
|
{
|
||||||
|
<p class="search-summary" role="status">@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.</p>
|
||||||
|
}
|
||||||
@if (Entries.Any(e => e.IsDeleted))
|
@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>
|
<label class="history-toggle"><input type="checkbox" @bind="showDeleted" /> Gelöschte Buchungen anzeigen (@Entries.Count(e => e.IsDeleted))</label>
|
||||||
@@ -24,6 +40,6 @@
|
|||||||
|
|
||||||
@if (!VisibleEntries.Any())
|
@if (!VisibleEntries.Any())
|
||||||
{
|
{
|
||||||
<div class="text-center py-3 text-muted">@EmptyMessage</div>
|
<div class="text-center py-3 text-muted">@(string.IsNullOrWhiteSpace(searchText) ? EmptyMessage : "Keine Treffer. Filter zurücksetzen zeigt wieder die Buchungen des gewählten Zeitraums.")</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ namespace Duempelkas.App.Components.Accounts;
|
|||||||
public partial class EntryTable
|
public partial class EntryTable
|
||||||
{
|
{
|
||||||
private bool showDeleted;
|
private bool showDeleted;
|
||||||
private IEnumerable<EntryDto> VisibleEntries => Entries.Where(e => !e.IsDeleted || showDeleted);
|
private string searchText = string.Empty;
|
||||||
|
private string sort = "date-asc";
|
||||||
|
private List<EntryDto> 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 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
|
||||||
|
|||||||
@@ -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<EntryDto> Apply(IEnumerable<EntryDto> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -697,3 +697,8 @@ button,summary { touch-action: manipulation; }
|
|||||||
.btn-success:hover { background: #166534; border-color: #166534; color: #fff; }
|
.btn-success:hover { background: #166534; border-color: #166534; color: #fff; }
|
||||||
.btn-info:hover { background: #1e40af; border-color: #1e40af; color: #fff; }
|
.btn-info:hover { background: #1e40af; border-color: #1e40af; color: #fff; }
|
||||||
.btn-danger:hover { background: #b91c1c; border-color: #b91c1c; 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); }
|
||||||
|
|||||||
@@ -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<EntryTable>(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<EntryDto> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user