refactor(app): implement year filtering for accounts and enhance dashboard PDF generation

This commit is contained in:
2026-04-03 14:24:46 +02:00
parent 4636acf7b0
commit 68c7a1ca6a
10 changed files with 208 additions and 34 deletions

View File

@@ -27,7 +27,7 @@
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" <button class="btn btn-success" @onclick="Save"
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Umbuchen")</button> disabled="@(!CanSave)"><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> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </div>

View File

@@ -39,8 +39,8 @@
<i class="bi bi-file-earmark-pdf"></i> PDF <i class="bi bi-file-earmark-pdf"></i> PDF
</button> </button>
| |
<button class="btn btn-nav @(selectedYear.HasValue ? "btn-primary" : "btn-outline-secondary")" @onclick="OpenYearFilterDialog"> <button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Filter<br>@GetFilterLabel() <i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
</button> </button>
</div> </div>

View File

@@ -4,6 +4,7 @@
@inject ISettingsService SettingsService @inject ISettingsService SettingsService
@inject IPdfStatementService PdfStatementService @inject IPdfStatementService PdfStatementService
@inject IFileSaveService FileSaveService @inject IFileSaveService FileSaveService
@inject IEntryService EntryService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="container-fluid"> <div class="container-fluid">
@@ -34,6 +35,10 @@
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync"> <button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
<i class="bi bi-file-earmark-pdf"></i> PDF <i class="bi bi-file-earmark-pdf"></i> PDF
</button> </button>
|
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
</button>
</div> </div>
@if (accounts != null && accounts.Any()) @if (accounts != null && accounts.Any())
@@ -107,3 +112,29 @@
OnCancel="CancelRestoreConfirm" /> OnCancel="CancelRestoreConfirm" />
} }
@if (showYearFilterDialog)
{
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<h5>Filter auswählen</h5>
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
<div class="filter-options-grid">
<button class="btn @(selectedYear.HasValue ? "btn-outline-secondary" : "btn-primary")" @onclick="() => SelectFilterYear(null)">
Alle Buchungen
</button>
@foreach (var year in availableYears)
{
<button class="btn @(selectedYear == year ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SelectFilterYear(year)">
@year
</button>
}
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-outline-secondary" @onclick="CloseYearFilterDialog">
<i class="bi bi-x-lg"></i> Schließen
</button>
</div>
</div>
</div>
}

View File

@@ -11,6 +11,9 @@ public partial class Dashboard
private bool showAddAccount; private bool showAddAccount;
private bool showEditClubName; private bool showEditClubName;
private bool showRestoreConfirm; private bool showRestoreConfirm;
private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year;
private List<int> availableYears = [];
private string clubName = string.Empty; private string clubName = string.Empty;
private string? operationMessage; private string? operationMessage;
private string operationMessageClass = "alert-info"; private string operationMessageClass = "alert-info";
@@ -31,16 +34,22 @@ public partial class Dashboard
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadClubName(); await LoadClubName();
await LoadAccounts(); await LoadAll();
} }
#endregion #endregion
#region Data Loading #region Data Loading
private async Task LoadAll()
{
await LoadAccounts();
availableYears = await EntryService.GetAllEntryYearsAsync();
}
private async Task LoadAccounts() private async Task LoadAccounts()
{ {
accounts = await AccountService.GetAllAccountsAsync(); accounts = await AccountService.GetAllAccountsAsync(selectedYear);
} }
private async Task LoadClubName() private async Task LoadClubName()
@@ -56,7 +65,7 @@ public partial class Dashboard
{ {
await AccountService.CreateAccountAsync(name); await AccountService.CreateAccountAsync(name);
showAddAccount = false; showAddAccount = false;
await LoadAccounts(); await LoadAll();
} }
private async Task HandleSaveClubName(string newName) private async Task HandleSaveClubName(string newName)
@@ -104,8 +113,26 @@ public partial class Dashboard
private async Task HandleDashboardExportAsync() private async Task HandleDashboardExportAsync()
{ {
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(); var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf"); var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
}
private void OpenYearFilterDialog()
{
showYearFilterDialog = true;
}
private void CloseYearFilterDialog()
{
showYearFilterDialog = false;
}
private async Task SelectFilterYear(int? year)
{
selectedYear = year;
showYearFilterDialog = false;
await LoadAll();
} }
private async Task HandleOpenSavedPdf() private async Task HandleOpenSavedPdf()
@@ -138,5 +165,7 @@ public partial class Dashboard
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €"; return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
} }
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
#endregion #endregion
} }

View File

@@ -5,6 +5,7 @@ namespace Duempelkas.App.Services;
public interface IAccountService public interface IAccountService
{ {
Task<List<AccountSummaryDto>> GetAllAccountsAsync(); Task<List<AccountSummaryDto>> GetAllAccountsAsync();
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
Task<AccountSummaryDto> GetAccountAsync(int accountId); Task<AccountSummaryDto> GetAccountAsync(int accountId);
Task<AccountSummaryDto> CreateAccountAsync(string name); Task<AccountSummaryDto> CreateAccountAsync(string name);
Task RenameAccountAsync(int accountId, string newName); Task RenameAccountAsync(int accountId, string newName);

View File

@@ -8,6 +8,7 @@ public interface IEntryService
Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year); Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year);
Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly); Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly);
Task<List<int>> GetEntryYearsAsync(int accountId); Task<List<int>> GetEntryYearsAsync(int accountId);
Task<List<int>> GetAllEntryYearsAsync();
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount); Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount); Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
Task DeleteEntryAsync(int entryId); Task DeleteEntryAsync(int entryId);

View File

@@ -5,4 +5,5 @@ public interface IPdfStatementService
Task<byte[]> GenerateStatementAsync(int accountId, int? year); Task<byte[]> GenerateStatementAsync(int accountId, int? year);
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly); Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
Task<byte[]> GenerateDashboardStatementAsync(); Task<byte[]> GenerateDashboardStatementAsync();
Task<byte[]> GenerateDashboardStatementAsync(int? year);
} }

View File

@@ -13,16 +13,60 @@ public class AccountService : IAccountService
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory; public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync() public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{
return GetAllAccountsAsync(null);
}
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts var accounts = await db.Accounts
.Include(a => a.Entries)
.OrderBy(a => a.Name) .OrderBy(a => a.Name)
.ToListAsync(); .ToListAsync();
return accounts.Select(MapToSummary).ToList(); var entries = db.Entries.Where(e => !e.IsDeleted);
if (year.HasValue)
{
var movementBeforeYearByAccountId = await entries
.Where(e => e.Date.Year < year.Value)
.GroupBy(e => e.AccountId)
.ToDictionaryAsync(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
var movementInYearByAccountId = await entries
.Where(e => e.Date.Year == year.Value)
.GroupBy(e => e.AccountId)
.ToDictionaryAsync(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
return accounts
.Select(account =>
{
var carryoverForYear = account.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(account.Id);
var totalBalance = carryoverForYear + movementInYearByAccountId.GetValueOrDefault(account.Id);
return new AccountSummaryDto(account.Id, account.Name, carryoverForYear, totalBalance, account.CreatedUtc);
})
.ToList();
}
var movementAllByAccountId = await entries
.GroupBy(e => e.AccountId)
.ToDictionaryAsync(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
return accounts
.Select(account =>
{
var totalBalance = account.CarryoverBalance + movementAllByAccountId.GetValueOrDefault(account.Id);
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
})
.ToList();
} }
public async Task<AccountSummaryDto> GetAccountAsync(int accountId) public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
@@ -34,7 +78,11 @@ public class AccountService : IAccountService
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
return MapToSummary(account); var totalIncome = account.Entries.Where(e => !e.IsDeleted && e.Type == EntryType.Income).Sum(e => e.Amount);
var totalExpense = account.Entries.Where(e => !e.IsDeleted && e.Type == EntryType.Expense).Sum(e => e.Amount);
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
} }
public async Task<AccountSummaryDto> CreateAccountAsync(string name) public async Task<AccountSummaryDto> CreateAccountAsync(string name)
@@ -115,12 +163,4 @@ public class AccountService : IAccountService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private static AccountSummaryDto MapToSummary(Account account)
{
var totalIncome = account.Entries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount);
var totalExpense = account.Entries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount);
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
}
} }

View File

@@ -77,6 +77,17 @@ public class EntryService : IEntryService
.ToListAsync(); .ToListAsync();
} }
public async Task<List<int>> GetAllEntryYearsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
.Select(e => e.Date.Year)
.Distinct()
.OrderByDescending(y => y)
.ToListAsync();
}
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();

View File

@@ -146,7 +146,12 @@ public class PdfStatementService : IPdfStatementService
return document.GeneratePdf(); return document.GeneratePdf();
} }
public async Task<byte[]> GenerateDashboardStatementAsync() public Task<byte[]> GenerateDashboardStatementAsync()
{
return GenerateDashboardStatementAsync(null);
}
public async Task<byte[]> GenerateDashboardStatementAsync(int? year)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -169,25 +174,65 @@ public class PdfStatementService : IPdfStatementService
transferByEntryId[transfer.TargetEntryId] = transfer; transferByEntryId[transfer.TargetEntryId] = transfer;
} }
var entries = await db.Entries var entriesQuery = db.Entries
.Include(e => e.Account) .Include(e => e.Account)
.Where(e => !e.IsDeleted) .Where(e => !e.IsDeleted);
if (year.HasValue)
{
entriesQuery = entriesQuery.Where(e => e.Date.Year == year.Value);
}
var entries = await entriesQuery
.OrderBy(e => e.Date) .OrderBy(e => e.Date)
.ThenBy(e => e.CreatedUtc) .ThenBy(e => e.CreatedUtc)
.ToListAsync(); .ToListAsync();
Dictionary<int, decimal> balanceByAccountId;
Dictionary<int, decimal>? carryoverByAccountId = null;
if (year.HasValue)
{
var movementBeforeYearByAccountId = await db.Entries
.Where(e => !e.IsDeleted && e.Date.Year < year.Value)
.GroupBy(e => e.AccountId)
.ToDictionaryAsync(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
var movementInYearByAccountId = entries
.GroupBy(e => e.AccountId)
.ToDictionary(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
carryoverByAccountId = accounts
.ToDictionary(
a => a.Id,
a => a.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(a.Id));
balanceByAccountId = accounts
.ToDictionary(
a => a.Id,
a => a.CarryoverBalance
+ movementBeforeYearByAccountId.GetValueOrDefault(a.Id)
+ movementInYearByAccountId.GetValueOrDefault(a.Id));
}
else
{
var movementByAccountId = entries var movementByAccountId = entries
.GroupBy(e => e.AccountId) .GroupBy(e => e.AccountId)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
var balanceByAccountId = accounts balanceByAccountId = accounts
.ToDictionary( .ToDictionary(
a => a.Id, a => a.Id,
a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
}
var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); var totalClubBalance = balanceByAccountId.Values.Sum();
var processedTransferIds = new HashSet<int>(); var processedTransferIds = new HashSet<int>();
var rows = new List<DashboardStatementRow>(); var rows = new List<DashboardStatementRow>();
@@ -248,7 +293,10 @@ public class PdfStatementService : IPdfStatementService
page.Header().Column(col => page.Header().Column(col =>
{ {
col.Item().Text(clubName).Bold().FontSize(18); col.Item().Text(clubName).Bold().FontSize(18);
col.Item().Text("Übersicht aller Konten").FontSize(13).FontColor(Colors.Grey.Darken1); var headerTitle = year.HasValue
? $"Übersicht aller Konten ({year.Value})"
: "Übersicht aller Konten";
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}") col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10) .FontSize(10)
.SemiBold() .SemiBold()
@@ -285,6 +333,18 @@ public class PdfStatementService : IPdfStatementService
} }
}); });
if (year.HasValue && carryoverByAccountId is not null)
{
BodyCell(table, string.Empty);
BodyCell(table, string.Empty);
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
foreach (var account in accounts)
{
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
}
}
foreach (var row in rows) foreach (var row in rows)
{ {
BodyCell(table, row.DisplayId); BodyCell(table, row.DisplayId);