refactor(app): implement year filter for account entries and update related services

This commit is contained in:
2026-04-03 14:11:42 +02:00
parent 08185f88cd
commit 4636acf7b0
11 changed files with 196 additions and 42 deletions

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 @(showCurrentYearOnly ? "btn-primary" : "btn-outline-secondary")" @onclick="ToggleYearFilter"> <button class="btn btn-nav @(selectedYear.HasValue ? "btn-primary" : "btn-outline-secondary")" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> @(showCurrentYearOnly ? $"Nur {DateTime.Now.Year}" : "Alle Buchungen") <i class="bi bi-funnel"></i> Filter<br>@GetFilterLabel()
</button> </button>
</div> </div>
@@ -56,21 +56,21 @@
</div> </div>
<div class="text-end"> <div class="text-end">
<small class="text-muted">Einnahmen</small> <small class="text-muted">Einnahmen</small>
<div class="amount-positive">@FormatAmount(showCurrentYearOnly ? balance.CurrentYearIncome : balance.TotalIncome)</div> <div class="amount-positive">@FormatAmount(selectedYear.HasValue ? balance.CurrentYearIncome : balance.TotalIncome)</div>
</div> </div>
<div class="text-end"> <div class="text-end">
<small class="text-muted">Ausgaben</small> <small class="text-muted">Ausgaben</small>
<div class="amount-negative">@FormatAmount(showCurrentYearOnly ? balance.CurrentYearExpense : balance.TotalExpense)</div> <div class="amount-negative">@FormatAmount(selectedYear.HasValue ? balance.CurrentYearExpense : balance.TotalExpense)</div>
</div> </div>
</div> </div>
</div> </div>
@if (showCurrentYearOnly) @if (selectedYear.HasValue)
{ {
<div class="summary-section"> <div class="summary-section">
<div class="summary-flex"> <div class="summary-flex">
<div> <div>
<small class="text-muted">Übertrag von @(DateTime.Now.Year - 1)</small> <small class="text-muted">Übertrag von @(selectedYear.Value - 1)</small>
<div class="d-flex align-items-center gap-1"> <div class="d-flex align-items-center gap-1">
<span class="fw-bold">@FormatAmount(balance.CarryoverBalance)</span> <span class="fw-bold">@FormatAmount(balance.CarryoverBalance)</span>
<button class="btn-edit-pen" @onclick="() => showEditCarryover = true" title="Übertrag bearbeiten"> <button class="btn-edit-pen" @onclick="() => showEditCarryover = true" title="Übertrag bearbeiten">
@@ -79,7 +79,7 @@
</div> </div>
</div> </div>
<div class="text-end"> <div class="text-end">
<small class="text-muted">Umsätze @DateTime.Now.Year</small> <small class="text-muted">Umsätze @selectedYear.Value</small>
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")"> <div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense) @FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
</div> </div>
@@ -179,3 +179,29 @@
OnCancel="() => editingTransferEntry = null" /> OnCancel="() => editingTransferEntry = null" />
} }
@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

@@ -21,7 +21,9 @@ public partial class AccountDetail
private bool showAddTransfer; private bool showAddTransfer;
private bool showEditName; private bool showEditName;
private bool showEditCarryover; private bool showEditCarryover;
private bool showCurrentYearOnly = true; private bool showYearFilterDialog;
private int? selectedYear = DateTime.Now.Year;
private List<int> availableYears = [];
private int? confirmDeleteEntryId; private int? confirmDeleteEntryId;
private string? confirmDeleteEntryTitle; private string? confirmDeleteEntryTitle;
@@ -56,14 +58,26 @@ public partial class AccountDetail
private async Task LoadAll() private async Task LoadAll()
{ {
account = await AccountService.GetAccountAsync(AccountId); account = await AccountService.GetAccountAsync(AccountId);
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId); balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly); entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
availableYears = await EntryService.GetEntryYearsAsync(AccountId);
} }
private async Task ToggleYearFilter() private void OpenYearFilterDialog()
{ {
showCurrentYearOnly = !showCurrentYearOnly; showYearFilterDialog = true;
entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly); }
private void CloseYearFilterDialog()
{
showYearFilterDialog = false;
}
private async Task SelectFilterYear(int? year)
{
selectedYear = year;
showYearFilterDialog = false;
await LoadAll();
} }
#endregion #endregion
@@ -79,7 +93,7 @@ public partial class AccountDetail
private async Task HandleSaveCarryover(decimal newAmount) private async Task HandleSaveCarryover(decimal newAmount)
{ {
await AccountService.UpdateCarryoverAsync(AccountId, newAmount); await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
showEditCarryover = false; showEditCarryover = false;
await LoadAll(); await LoadAll();
} }
@@ -164,8 +178,8 @@ public partial class AccountDetail
private async Task HandleExport() private async Task HandleExport()
{ {
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, showCurrentYearOnly); var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
var suffix = showCurrentYearOnly ? $"_{DateTime.Now.Year}" : "_Gesamt"; var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf"); savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
} }
@@ -190,5 +204,7 @@ public partial class AccountDetail
private static string FormatAmount(decimal amount) => $"{amount:N2} €"; private static string FormatAmount(decimal amount) => $"{amount:N2} €";
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
#endregion #endregion
} }

View File

@@ -9,5 +9,6 @@ public interface IAccountService
Task<AccountSummaryDto> CreateAccountAsync(string name); Task<AccountSummaryDto> CreateAccountAsync(string name);
Task RenameAccountAsync(int accountId, string newName); Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task DeleteAccountAsync(int accountId); Task DeleteAccountAsync(int accountId);
} }

View File

@@ -4,5 +4,5 @@ namespace Duempelkas.App.Services;
public interface IBalanceQueryService public interface IBalanceQueryService
{ {
Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId); Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null);
} }

View File

@@ -5,7 +5,9 @@ namespace Duempelkas.App.Services;
public interface IEntryService public interface IEntryService
{ {
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<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

@@ -2,6 +2,7 @@ namespace Duempelkas.App.Services;
public interface IPdfStatementService public interface IPdfStatementService
{ {
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();
} }

View File

@@ -138,6 +138,16 @@ html, body {
margin-bottom: 0.5rem !important; margin-bottom: 0.5rem !important;
} }
.filter-options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
}
.filter-options-grid .btn {
justify-content: center;
}
/* Navbar */ /* Navbar */
.app-navbar { .app-navbar {
background-color: var(--color-surface); background-color: var(--color-surface);

View File

@@ -67,6 +67,27 @@ public class AccountService : IAccountService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year)
{
if (!year.HasValue)
{
await UpdateCarryoverAsync(accountId, carryoverBalance);
return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Account {accountId} not found.");
var movementBeforeYear = await db.Entries
.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value)
.SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount);
account.CarryoverBalance = carryoverBalance - movementBeforeYear;
await db.SaveChangesAsync();
}
public async Task DeleteAccountAsync(int accountId) public async Task DeleteAccountAsync(int accountId)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();

View File

@@ -12,7 +12,7 @@ public class BalanceQueryService : IBalanceQueryService
public BalanceQueryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory; public BalanceQueryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId) public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -21,21 +21,33 @@ public class BalanceQueryService : IBalanceQueryService
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
var currentYear = DateTime.Now.Year; var selectedYear = year ?? DateTime.Now.Year;
var activeEntries = account.Entries.Where(e => !e.IsDeleted).ToList(); var activeEntries = account.Entries.Where(e => !e.IsDeleted).ToList();
var totalIncome = activeEntries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount); var totalIncome = activeEntries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount);
var totalExpense = activeEntries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount); var totalExpense = activeEntries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount);
var currentYearIncome = activeEntries.Where(e => e.Type == EntryType.Income && e.Date.Year == currentYear).Sum(e => e.Amount);
var currentYearExpense = activeEntries.Where(e => e.Type == EntryType.Expense && e.Date.Year == currentYear).Sum(e => e.Amount); var selectedYearIncome = activeEntries
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense; .Where(e => e.Type == EntryType.Income && e.Date.Year == selectedYear)
.Sum(e => e.Amount);
var selectedYearExpense = activeEntries
.Where(e => e.Type == EntryType.Expense && e.Date.Year == selectedYear)
.Sum(e => e.Amount);
var selectedYearCarryover = account.CarryoverBalance + activeEntries
.Where(e => e.Date.Year < selectedYear)
.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount);
var totalBalance = year.HasValue
? selectedYearCarryover + selectedYearIncome - selectedYearExpense
: account.CarryoverBalance + totalIncome - totalExpense;
return new AccountBalanceDto( return new AccountBalanceDto(
account.CarryoverBalance, year.HasValue ? selectedYearCarryover : account.CarryoverBalance,
totalIncome, totalIncome,
totalExpense, totalExpense,
currentYearIncome, selectedYearIncome,
currentYearExpense, selectedYearExpense,
totalBalance); totalBalance);
} }
} }

View File

@@ -13,14 +13,20 @@ public class EntryService : IEntryService
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory; public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
return GetEntriesAsync(accountId, year);
}
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId); var query = db.Entries.Where(e => e.AccountId == accountId);
if (currentYearOnly) if (year.HasValue)
query = query.Where(e => e.Date.Year == DateTime.Now.Year); query = query.Where(e => e.Date.Year == year.Value);
var entries = await query var entries = await query
.OrderBy(e => e.Date) .OrderBy(e => e.Date)
@@ -59,6 +65,18 @@ public class EntryService : IEntryService
}).ToList(); }).ToList();
} }
public async Task<List<int>> GetEntryYearsAsync(int accountId)
{
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
.Where(e => e.AccountId == accountId)
.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

@@ -27,19 +27,25 @@ public class PdfStatementService : IPdfStatementService
_settingsService = settingsService; _settingsService = settingsService;
} }
public async Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly) public Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
{
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
return GenerateStatementAsync(accountId, year);
}
public async Task<byte[]> GenerateStatementAsync(int accountId, int? year)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId) var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly); var entries = await _entryService.GetEntriesAsync(accountId, year);
var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId); var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year);
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein"; var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
var title = currentYearOnly var title = year.HasValue
? $"{account.Name} Auszug {DateTime.Now.Year}" ? $"{account.Name} Auszug {year.Value}"
: $"{account.Name} Gesamtauszug"; : $"{account.Name} Gesamtauszug";
var document = Document.Create(container => var document = Document.Create(container =>
@@ -61,12 +67,6 @@ public class PdfStatementService : IPdfStatementService
page.Content().PaddingTop(15).Column(col => page.Content().PaddingTop(15).Column(col =>
{ {
col.Item().PaddingBottom(10).Row(row =>
{
row.RelativeItem().Text("Übertrag:").SemiBold();
row.AutoItem().Text(FormatCurrency(balance.CarryoverBalance)).SemiBold();
});
col.Item().Table(table => col.Item().Table(table =>
{ {
table.ColumnsDefinition(columns => table.ColumnsDefinition(columns =>
@@ -114,9 +114,16 @@ public class PdfStatementService : IPdfStatementService
col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(10).Column(summaryCol => col.Item().PaddingTop(10).Column(summaryCol =>
{ {
SummaryRow(summaryCol, "Übertrag:", balance.CarryoverBalance); var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome;
SummaryRow(summaryCol, "Einnahmen gesamt:", balance.TotalIncome); var expenseValue = year.HasValue ? balance.CurrentYearExpense : balance.TotalExpense;
SummaryRow(summaryCol, "Ausgaben gesamt:", -balance.TotalExpense);
if (year.HasValue)
{
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
}
SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
summaryCol.Item().PaddingTop(5).Row(row => summaryCol.Item().PaddingTop(5).Row(row =>
{ {
row.RelativeItem().Text("Saldo:").Bold().FontSize(12); row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
@@ -175,6 +182,11 @@ public class PdfStatementService : IPdfStatementService
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
.ToDictionary(
a => a.Id,
a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
var processedTransferIds = new HashSet<int>(); var processedTransferIds = new HashSet<int>();
@@ -291,6 +303,16 @@ public class PdfStatementService : IPdfStatementService
} }
} }
} }
// Add one summary line with the final balance for each account column.
FooterCell(table, string.Empty);
FooterCell(table, string.Empty);
FooterCell(table, "Kontostand", alignRight: true);
foreach (var account in accounts)
{
FooterAmountCell(table, balanceByAccountId.GetValueOrDefault(account.Id));
}
}); });
content.Item().PaddingTop(8).AlignRight().Text(text => content.Item().PaddingTop(8).AlignRight().Text(text =>
@@ -352,6 +374,31 @@ public class PdfStatementService : IPdfStatementService
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1); .FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
} }
private static void FooterCell(TableDescriptor table, string text, bool alignRight = false)
{
var cell = table.Cell()
.BorderTop(1)
.BorderColor(Colors.Grey.Darken1)
.PaddingTop(5)
.PaddingBottom(2);
var content = alignRight ? cell.AlignRight() : cell;
content.Text(text).SemiBold();
}
private static void FooterAmountCell(TableDescriptor table, decimal amount)
{
table.Cell()
.BorderTop(1)
.BorderColor(Colors.Grey.Darken1)
.PaddingTop(5)
.PaddingBottom(2)
.AlignRight()
.Text(FormatCurrency(amount))
.SemiBold()
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
}
private static int ParseDisplayIdYear(string displayId) private static int ParseDisplayIdYear(string displayId)
{ {
var parts = displayId.Split('-'); var parts = displayId.Split('-');