feat(usability): preserve reporting year and locate outside-period bookings (U04)

This commit is contained in:
2026-11-02 06:28:36 +01:00
parent 122d4de809
commit 9c41856b5b
16 changed files with 169 additions and 26 deletions
@@ -20,6 +20,6 @@
@if (!Entries.Any())
{
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div>
<div class="text-center py-3 text-muted">@EmptyMessage</div>
}
@@ -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]
@@ -24,9 +24,17 @@
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div>
@if (OutsideYear)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0 || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))">
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
@@ -15,13 +15,18 @@ public partial class AddEntryDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
public EventCallback<DateTime> 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();
@@ -25,9 +25,17 @@
<input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div>
@if (OutsideYear)
{
<div class="alert alert-warning" role="status">
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
<label><input type="checkbox" checked="@(confirmDifferentYear && confirmedYear == date.Year)" @onchange="e => { confirmDifferentYear = e.Value is true; confirmedYear = date.Year; }" /> Ich möchte die Buchung im Jahr @date.Year speichern.</label>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
disabled="@(!CanSave || (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)))"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</div>
@@ -14,13 +14,18 @@ public partial class AddTransferDialog
public EntryDto? EditEntry { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
public EventCallback<DateTime> 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<AccountSummaryDto> 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();
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/accounts/{AccountId:int}"
@inject IAccountService AccountService
@inject IEntryService EntryService
@@ -10,6 +11,13 @@
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (savedOutsideYear.HasValue)
{
<div class="alert alert-info" role="status">
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
</div>
}
@if (account == null)
{
<div class="text-center py-5">
@@ -43,10 +51,11 @@
</button>
|
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
<i class="bi bi-funnel"></i> @GetFilterLabel()
</button>
</div>
<p class="text-muted">Zeitraum für Liste und Summen: @Period.Label</p>
@if (balance != null)
{
<div class="summary-section">
@@ -96,7 +105,7 @@
@if (entries != null)
{
<div class="entries-scroll-wrapper">
<EntryTable Entries="entries" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
<EntryTable Entries="entries" EmptyMessage="emptyEntriesMessage" OnDeleteEntry="RequestDeleteEntry" OnRestoreEntry="RequestRestoreEntry" OnEditEntry="RequestEditEntry" />
</div>
}
}
@@ -104,14 +113,14 @@
@if (showAddEntry)
{
<AddEntryDialog AccountId="AccountId"
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleEntryCreated"
OnCancel="() => showAddEntry = false" />
}
@if (showAddTransfer)
{
<AddTransferDialog SourceAccountId="AccountId"
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
OnSave="HandleTransferCreated"
OnCancel="() => showAddTransfer = false" />
}
@@ -168,7 +177,7 @@
@if (editingEntry != null)
{
<AddEntryDialog AccountId="AccountId"
<AddEntryDialog AccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingEntry"
OnSave="HandleEntryEdited"
OnCancel="() => editingEntry = null" />
@@ -176,7 +185,7 @@
@if (editingTransferEntry != null)
{
<AddTransferDialog SourceAccountId="AccountId"
<AddTransferDialog SourceAccountId="AccountId" ContextYear="selectedYear"
EditEntry="editingTransferEntry"
OnSave="HandleEntryEdited"
OnCancel="() => editingTransferEntry = null" />
@@ -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<int> 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
}
+3 -2
View File
@@ -1,3 +1,4 @@
@inject ReportingPeriod Period
@page "/"
@inject IAccountService AccountService
@inject IBackupService BackupService
@@ -40,7 +41,7 @@
</button>
|
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
<i class="bi bi-funnel"></i> @GetFilterLabel()
</button>
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
@@ -50,7 +51,7 @@
@if (accounts != null && accounts.Any())
{
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
<strong>Summe aller Konten:</strong>
<strong>Summe aller Konten – @Period.Label:</strong>
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
@FormatCurrency(TotalClubBalance)
</span>
+3 -3
View File
@@ -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<int> 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";
@@ -0,0 +1,11 @@
namespace Duempelkas.App.Services;
/// <summary>The user's explicit selection survives navigation, including All years.</summary>
public sealed class ReportingPeriod
{
public int? Year { get; set; } = DateTime.Today.Year;
public string Label => Year.HasValue ? $"Jahr: {Year}" : "Alle Jahre";
public List<int> Choices(IEnumerable<int> entryYears) => entryYears
.Append(DateTime.Today.Year).Concat(Year.HasValue ? [Year.Value] : Array.Empty<int>())
.Distinct().OrderDescending().ToList();
}
@@ -11,8 +11,8 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
// The factory also registers FinanceDbContext as scoped. Registering
// AddDbContext first would give its singleton factory scoped options.
services.AddDbContextFactory<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
@@ -24,6 +24,7 @@ public static class DependencyInjection
services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<ReportingPeriod>();
return services;
}