diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md
index 7d1388c..0119cd8 100644
--- a/docs/usability-umsetzung.md
+++ b/docs/usability-umsetzung.md
@@ -27,3 +27,7 @@ Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen
## U03 – Gleiche Bedeutung in PDF und Oberfläche
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
+
+## U04 – Jahreskontext
+
+Gemeinsame explizite Jahresauswahl bleibt beim Wechsel Übersicht/Konto/Zurück erhalten, auch „Alle Jahre“. Auswahl enthält aktuelles und gewähltes Jahr auch ohne Buchungen. Liste und Summen benennen den Zeitraum; Leerzustände unterscheiden fehlende Buchungen und leeren Zeitraum. Abweichendes Datum bleibt unverändert, erfordert eine gezielte Bestätigung im Formular und bietet nach Erfolg „Buchung im Jahr … anzeigen“. Prüfung: 21/21 Debug-Tests, einschließlich gerenderter Navigation 2025 → Konto → Zurück, Buchung 2026 bei Filter 2025 und anschließendem Aufruf des gespeicherten Jahres. Dabei bestehende doppelte DbContext-Registrierung korrigiert, da die Singleton-Factory sonst scoped Optionen konsumiert.
diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor b/src/Duempelkas.App/Components/Accounts/EntryTable.razor
index 0ad0a17..ed26cd5 100644
--- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor
+++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor
@@ -20,6 +20,6 @@
@if (!Entries.Any())
{
-
Keine Buchungen vorhanden.
+ @EmptyMessage
}
diff --git a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
index 87236e0..cd18e92 100644
--- a/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
+++ b/src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
@@ -5,6 +5,7 @@ namespace Duempelkas.App.Components.Accounts;
public partial class EntryTable
{
+ [Parameter] public string EmptyMessage { get; set; } = "Keine Buchungen vorhanden.";
#region Parameters
[Parameter]
diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor
index bfda88b..fcb637f 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor
+++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor
@@ -24,9 +24,17 @@
+ @if (OutsideYear)
+ {
+
+ Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
+ { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.
+
+ }
+
+ disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0 || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))">
@(EditEntry != null ? "Speichern" : "Hinzufügen")
Abbrechen
diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
index 327dbbd..e7bfc0f 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
+++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
@@ -15,13 +15,18 @@ public partial class AddEntryDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
- public EventCallback OnSave { get; set; }
+ public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
#endregion
+ [Parameter] public int? ContextYear { get; set; }
+ private bool confirmDifferentYear;
+ private int? confirmedYear;
+ private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
+
#region Fields
private EntryType entryType = EntryType.Income;
@@ -50,6 +55,7 @@ public partial class AddEntryDialog
private async Task Save()
{
+ if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
if (EditEntry != null)
{
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
@@ -59,7 +65,7 @@ public partial class AddEntryDialog
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
}
- await OnSave.InvokeAsync();
+ await OnSave.InvokeAsync(date);
}
private async Task Cancel() => await OnCancel.InvokeAsync();
diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
index 827651a..807e8c5 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
+++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
@@ -25,9 +25,17 @@
+ @if (OutsideYear)
+ {
+
+ Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
+ { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.
+
+ }
+
@(EditEntry != null ? "Speichern" : "Hinzufügen")
+ disabled="@(!CanSave || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))"> @(EditEntry != null ? "Speichern" : "Hinzufügen")
Abbrechen
diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
index 28bb846..6e1541c 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
+++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
@@ -14,13 +14,18 @@ public partial class AddTransferDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
- public EventCallback OnSave { get; set; }
+ public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
#endregion
+ [Parameter] public int? ContextYear { get; set; }
+ private bool confirmDifferentYear;
+ private int? confirmedYear;
+ private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
+
#region Fields
private List accounts = new();
@@ -61,6 +66,7 @@ public partial class AddTransferDialog
private async Task Save()
{
+ if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
if (EditEntry != null)
{
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
@@ -70,7 +76,7 @@ public partial class AddTransferDialog
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
}
- await OnSave.InvokeAsync();
+ await OnSave.InvokeAsync(date);
}
private async Task Cancel() => await OnCancel.InvokeAsync();
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
index 7d836fb..966d038 100644
--- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
+++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
@@ -1,3 +1,4 @@
+@inject ReportingPeriod Period
@page "/accounts/{AccountId:int}"
@inject IAccountService AccountService
@inject IEntryService EntryService
@@ -10,6 +11,13 @@
@if (fileBusy) { Dateiaktion läuft … Bitte warten.
}
+ @if (savedOutsideYear.HasValue)
+ {
+
+ Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
+ Buchung im Jahr @savedOutsideYear anzeigen
+
+ }
@if (account == null)
{
@@ -43,10 +51,11 @@
|
- Ansicht @GetFilterLabel()
+ @GetFilterLabel()
+ Zeitraum für Liste und Summen: @Period.Label
@if (balance != null)
{
@@ -96,7 +105,7 @@
@if (entries != null)
{
-
+
}
}
@@ -104,14 +113,14 @@
@if (showAddEntry)
{
-
}
@if (showAddTransfer)
{
-
}
@@ -168,7 +177,7 @@
@if (editingEntry != null)
{
-
@@ -176,7 +185,7 @@
@if (editingTransferEntry != null)
{
-
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
index 80139e9..e28db1f 100644
--- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
+++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
@@ -22,7 +22,7 @@ public partial class AccountDetail
private bool showEditName;
private bool showEditCarryover;
private bool showYearFilterDialog;
- private int? selectedYear = DateTime.Now.Year;
+ private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List
availableYears = [];
private int? confirmDeleteEntryId;
@@ -35,6 +35,8 @@ public partial class AccountDetail
private FileOperationResult? fileResult;
private bool fileBusy;
+ private int? savedOutsideYear;
+ private string emptyEntriesMessage = "Noch keine Buchungen vorhanden.";
private EntryDto? editingEntry;
private EntryDto? editingTransferEntry;
@@ -62,7 +64,9 @@ public partial class AccountDetail
account = await AccountService.GetAccountAsync(AccountId);
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
- availableYears = await EntryService.GetEntryYearsAsync(AccountId);
+ var entryYears = await EntryService.GetEntryYearsAsync(AccountId);
+ availableYears = Period.Choices(entryYears);
+ emptyEntriesMessage = entryYears.Count == 0 ? "Noch keine Buchungen vorhanden." : "Keine Buchungen in diesem Zeitraum. Wähle ein anderes Jahr oder Alle Jahre.";
}
private void OpenYearFilterDialog()
@@ -78,6 +82,7 @@ public partial class AccountDetail
private async Task SelectFilterYear(int? year)
{
selectedYear = year;
+ savedOutsideYear = null;
showYearFilterDialog = false;
await LoadAll();
}
@@ -100,18 +105,22 @@ public partial class AccountDetail
await LoadAll();
}
- private async Task HandleEntryCreated()
+ private async Task HandleEntryCreated(DateTime savedDate)
{
+ savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
showAddEntry = false;
await LoadAll();
}
- private async Task HandleTransferCreated()
+ private async Task HandleTransferCreated(DateTime savedDate)
{
+ savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
showAddTransfer = false;
await LoadAll();
}
+ private Task ShowSavedYear() => SelectFilterYear(savedOutsideYear);
+
private void RequestDeleteEntry(int entryId)
{
confirmDeleteEntryId = entryId;
@@ -171,8 +180,9 @@ public partial class AccountDetail
}
}
- private async Task HandleEntryEdited()
+ private async Task HandleEntryEdited(DateTime savedDate)
{
+ savedOutsideYear = selectedYear.HasValue && selectedYear.Value != savedDate.Year ? savedDate.Year : null;
editingEntry = null;
editingTransferEntry = null;
await LoadAll();
@@ -226,7 +236,7 @@ public partial class AccountDetail
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
- private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
+ private string GetFilterLabel() => Period.Label;
#endregion
}
diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor
index 638eda9..9154fee 100644
--- a/src/Duempelkas.App/Pages/Dashboard.razor
+++ b/src/Duempelkas.App/Pages/Dashboard.razor
@@ -1,3 +1,4 @@
+@inject ReportingPeriod Period
@page "/"
@inject IAccountService AccountService
@inject IBackupService BackupService
@@ -40,7 +41,7 @@
|
- Ansicht @GetFilterLabel()
+ @GetFilterLabel()
Modus @ThemeButtonLabel
@@ -50,7 +51,7 @@
@if (accounts != null && accounts.Any())
{
-
Summe aller Konten:
+
Summe aller Konten – @Period.Label:
@FormatCurrency(TotalClubBalance)
diff --git a/src/Duempelkas.App/Pages/Dashboard.razor.cs b/src/Duempelkas.App/Pages/Dashboard.razor.cs
index fed810d..9629c83 100644
--- a/src/Duempelkas.App/Pages/Dashboard.razor.cs
+++ b/src/Duempelkas.App/Pages/Dashboard.razor.cs
@@ -14,7 +14,7 @@ public partial class Dashboard
private bool showEditClubName;
private bool showRestoreConfirm;
private bool showYearFilterDialog;
- private int? selectedYear = DateTime.Now.Year;
+ private int? selectedYear { get => Period.Year; set => Period.Year = value; }
private List
availableYears = [];
private string clubName = string.Empty;
private string? savedPdfPath;
@@ -67,7 +67,7 @@ public partial class Dashboard
private async Task LoadAll()
{
await LoadAccounts();
- availableYears = await EntryService.GetAllEntryYearsAsync();
+ availableYears = Period.Choices(await EntryService.GetAllEntryYearsAsync());
}
private async Task LoadAccounts()
@@ -207,7 +207,7 @@ public partial class Dashboard
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
}
- private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
+ private string GetFilterLabel() => Period.Label;
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
diff --git a/src/Duempelkas.App/Services/ReportingPeriod.cs b/src/Duempelkas.App/Services/ReportingPeriod.cs
new file mode 100644
index 0000000..92438e3
--- /dev/null
+++ b/src/Duempelkas.App/Services/ReportingPeriod.cs
@@ -0,0 +1,11 @@
+namespace Duempelkas.App.Services;
+
+/// The user's explicit selection survives navigation, including All years.
+public sealed class ReportingPeriod
+{
+ public int? Year { get; set; } = DateTime.Today.Year;
+ public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
+ public List Choices(IEnumerable entryYears) => entryYears
+ .Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty())
+ .Distinct().OrderDescending().ToList();
+}
diff --git a/src/Duempelkas.Infrastructure/DependencyInjection.cs b/src/Duempelkas.Infrastructure/DependencyInjection.cs
index f2e7348..853929f 100644
--- a/src/Duempelkas.Infrastructure/DependencyInjection.cs
+++ b/src/Duempelkas.Infrastructure/DependencyInjection.cs
@@ -11,8 +11,8 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
- services.AddDbContext(options =>
- options.UseSqlite(connectionString));
+ // The factory also registers FinanceDbContext as scoped. Registering
+ // AddDbContext first would give its singleton factory scoped options.
services.AddDbContextFactory(options =>
options.UseSqlite(connectionString));
@@ -24,6 +24,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
return services;
}
diff --git a/tests/Duempelkas.Tests/Duempelkas.Tests.csproj b/tests/Duempelkas.Tests/Duempelkas.Tests.csproj
index 117d37e..28f95fe 100644
--- a/tests/Duempelkas.Tests/Duempelkas.Tests.csproj
+++ b/tests/Duempelkas.Tests/Duempelkas.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/tests/Duempelkas.Tests/ReportingPeriodTests.cs b/tests/Duempelkas.Tests/ReportingPeriodTests.cs
new file mode 100644
index 0000000..7bf3f05
--- /dev/null
+++ b/tests/Duempelkas.Tests/ReportingPeriodTests.cs
@@ -0,0 +1,19 @@
+using Duempelkas.App.Services;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class ReportingPeriodTests
+{
+ [Fact]
+ public void EmptyYearList_PreservesSelectedYear_AndOffersCurrentYear()
+ {
+ var period = new ReportingPeriod { Year = 2025 };
+ Assert.Contains(2025, period.Choices([]));
+ Assert.Contains(DateTime.Today.Year, period.Choices([]));
+ Assert.Equal("Jahr: 2025", period.Label);
+ period.Year = null;
+ Assert.Equal("Alle Jahre", period.Label);
+ Assert.Null(period.Year);
+ }
+}
diff --git a/tests/Duempelkas.Tests/YearNavigationTests.cs b/tests/Duempelkas.Tests/YearNavigationTests.cs
new file mode 100644
index 0000000..6552e9e
--- /dev/null
+++ b/tests/Duempelkas.Tests/YearNavigationTests.cs
@@ -0,0 +1,58 @@
+using Bunit;
+using Duempelkas.App.Components;
+using Duempelkas.App.Components.Dialogs;
+using Duempelkas.App.Services;
+using Duempelkas.Domain.Enums;
+using Duempelkas.Infrastructure;
+using Duempelkas.Infrastructure.Persistence;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class YearNavigationTests
+{
+ [Fact]
+ public async Task Navigation_PreservesYear_AndOutsideBookingOffersReturn()
+ {
+ using var context = new BunitContext();
+ var connectionString = $"Data Source=year-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+ using var anchor = new SqliteConnection(connectionString);
+ anchor.Open();
+ context.Services.AddInfrastructure(connectionString);
+ context.JSInterop.Mode = JSRuntimeMode.Loose;
+ using (var db = context.Services.GetRequiredService>().CreateDbContext())
+ {
+ db.Database.EnsureCreated();
+ db.Accounts.Add(new() { Name = "Testkasse" });
+ db.SaveChanges();
+ await context.Services.GetRequiredService().CreateEntryAsync(db.Accounts.Single().Id,
+ EntryType.Income, new(2025, 1, 1), "Vorjahr", 10m);
+ }
+ context.Services.GetRequiredService().Year = 2025;
+ var page = context.Render();
+ page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
+ page.Find(".account-card").Click();
+ page.WaitForAssertion(() => Assert.Contains("Vorjahr", page.Markup));
+ page.FindAll("button").Single(b => b.TextContent.Contains("Zurück")).Click();
+ page.WaitForAssertion(() => Assert.Contains("Jahr: 2025", page.Markup));
+ page.Find(".account-card").Click();
+ page.WaitForElement(".summary-section");
+ page.FindAll("button").Single(b => b.TextContent.Trim() == "Buchung").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");
+ Assert.Contains("außerhalb", dialog.Markup);
+ Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ dialog.Find("input[type=checkbox]").Change(true);
+ dialog.Find("button.btn-success").Click();
+ page.WaitForAssertion(() => Assert.Contains("Buchung im Jahr 2026 anzeigen", page.Markup));
+ Assert.DoesNotContain("Außerhalb", page.Find("table").TextContent);
+ page.FindAll("button").Single(b => b.TextContent.Contains("Buchung im Jahr 2026 anzeigen")).Click();
+ page.WaitForAssertion(() => Assert.Contains("Außerhalb", page.Find("table").TextContent));
+ Assert.Equal(2026, context.Services.GetRequiredService().Year);
+ }
+}