-
Übertrag von @(DateTime.Now.Year - 1)
+
Übertrag von @(selectedYear.Value - 1)
@FormatAmount(balance.CarryoverBalance)
-
Umsätze @DateTime.Now.Year
+
Umsätze @selectedYear.Value
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
@@ -179,3 +179,29 @@
OnCancel="() => editingTransferEntry = null" />
}
+@if (showYearFilterDialog)
+{
+
+
+
Filter auswählen
+
Wähle ein Jahr oder alle Buchungen.
+
+
+ @foreach (var year in availableYears)
+ {
+
+ }
+
+
+
+
+
+
+}
+
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
index 2dac2ed..bc295ce 100644
--- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
+++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
@@ -21,7 +21,9 @@ public partial class AccountDetail
private bool showAddTransfer;
private bool showEditName;
private bool showEditCarryover;
- private bool showCurrentYearOnly = true;
+ private bool showYearFilterDialog;
+ private int? selectedYear = DateTime.Now.Year;
+ private List
availableYears = [];
private int? confirmDeleteEntryId;
private string? confirmDeleteEntryTitle;
@@ -56,14 +58,26 @@ public partial class AccountDetail
private async Task LoadAll()
{
account = await AccountService.GetAccountAsync(AccountId);
- balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId);
- entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly);
+ balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
+ entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
+ availableYears = await EntryService.GetEntryYearsAsync(AccountId);
}
- private async Task ToggleYearFilter()
+ private void OpenYearFilterDialog()
{
- showCurrentYearOnly = !showCurrentYearOnly;
- entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly);
+ showYearFilterDialog = true;
+ }
+
+ private void CloseYearFilterDialog()
+ {
+ showYearFilterDialog = false;
+ }
+
+ private async Task SelectFilterYear(int? year)
+ {
+ selectedYear = year;
+ showYearFilterDialog = false;
+ await LoadAll();
}
#endregion
@@ -79,7 +93,7 @@ public partial class AccountDetail
private async Task HandleSaveCarryover(decimal newAmount)
{
- await AccountService.UpdateCarryoverAsync(AccountId, newAmount);
+ await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
showEditCarryover = false;
await LoadAll();
}
@@ -164,8 +178,8 @@ public partial class AccountDetail
private async Task HandleExport()
{
- var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, showCurrentYearOnly);
- var suffix = showCurrentYearOnly ? $"_{DateTime.Now.Year}" : "_Gesamt";
+ var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
+ var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
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 string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
+
#endregion
}
diff --git a/src/Duempelkas.App/Services/IAccountService.cs b/src/Duempelkas.App/Services/IAccountService.cs
index 61861b7..3aa34a0 100644
--- a/src/Duempelkas.App/Services/IAccountService.cs
+++ b/src/Duempelkas.App/Services/IAccountService.cs
@@ -9,5 +9,6 @@ public interface IAccountService
Task CreateAccountAsync(string name);
Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
+ Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task DeleteAccountAsync(int accountId);
}
diff --git a/src/Duempelkas.App/Services/IBalanceQueryService.cs b/src/Duempelkas.App/Services/IBalanceQueryService.cs
index 20b07ce..2ffbe0d 100644
--- a/src/Duempelkas.App/Services/IBalanceQueryService.cs
+++ b/src/Duempelkas.App/Services/IBalanceQueryService.cs
@@ -4,5 +4,5 @@ namespace Duempelkas.App.Services;
public interface IBalanceQueryService
{
- Task GetAccountBalanceAsync(int accountId);
+ Task GetAccountBalanceAsync(int accountId, int? year = null);
}
diff --git a/src/Duempelkas.App/Services/IEntryService.cs b/src/Duempelkas.App/Services/IEntryService.cs
index e22f399..2cfb9f7 100644
--- a/src/Duempelkas.App/Services/IEntryService.cs
+++ b/src/Duempelkas.App/Services/IEntryService.cs
@@ -5,7 +5,9 @@ namespace Duempelkas.App.Services;
public interface IEntryService
{
+ Task> GetEntriesAsync(int accountId, int? year);
Task> GetEntriesAsync(int accountId, bool currentYearOnly);
+ Task> GetEntryYearsAsync(int accountId);
Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
Task DeleteEntryAsync(int entryId);
diff --git a/src/Duempelkas.App/Services/IPdfStatementService.cs b/src/Duempelkas.App/Services/IPdfStatementService.cs
index 7f320b2..2d03aa1 100644
--- a/src/Duempelkas.App/Services/IPdfStatementService.cs
+++ b/src/Duempelkas.App/Services/IPdfStatementService.cs
@@ -2,6 +2,7 @@ namespace Duempelkas.App.Services;
public interface IPdfStatementService
{
+ Task GenerateStatementAsync(int accountId, int? year);
Task GenerateStatementAsync(int accountId, bool currentYearOnly);
Task GenerateDashboardStatementAsync();
}
diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css
index 8ce1e2b..e2ac6ac 100644
--- a/src/Duempelkas.Desktop/wwwroot/css/app.css
+++ b/src/Duempelkas.Desktop/wwwroot/css/app.css
@@ -138,6 +138,16 @@ html, body {
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 */
.app-navbar {
background-color: var(--color-surface);
diff --git a/src/Duempelkas.Infrastructure/Services/AccountService.cs b/src/Duempelkas.Infrastructure/Services/AccountService.cs
index c68e652..105d493 100644
--- a/src/Duempelkas.Infrastructure/Services/AccountService.cs
+++ b/src/Duempelkas.Infrastructure/Services/AccountService.cs
@@ -67,6 +67,27 @@ public class AccountService : IAccountService
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)
{
await using var db = await _dbFactory.CreateDbContextAsync();
diff --git a/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs b/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs
index a0c542f..6f48a5b 100644
--- a/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs
+++ b/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs
@@ -12,7 +12,7 @@ public class BalanceQueryService : IBalanceQueryService
public BalanceQueryService(IDbContextFactory dbFactory) => _dbFactory = dbFactory;
- public async Task GetAccountBalanceAsync(int accountId)
+ public async Task GetAccountBalanceAsync(int accountId, int? year = null)
{
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -21,21 +21,33 @@ public class BalanceQueryService : IBalanceQueryService
.FirstOrDefaultAsync(a => a.Id == accountId)
?? 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 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 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 totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
+
+ var selectedYearIncome = activeEntries
+ .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(
- account.CarryoverBalance,
+ year.HasValue ? selectedYearCarryover : account.CarryoverBalance,
totalIncome,
totalExpense,
- currentYearIncome,
- currentYearExpense,
+ selectedYearIncome,
+ selectedYearExpense,
totalBalance);
}
}
diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs
index 2960a62..62492a8 100644
--- a/src/Duempelkas.Infrastructure/Services/EntryService.cs
+++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs
@@ -13,14 +13,20 @@ public class EntryService : IEntryService
public EntryService(IDbContextFactory dbFactory) => _dbFactory = dbFactory;
- public async Task> GetEntriesAsync(int accountId, bool currentYearOnly)
+ public Task> GetEntriesAsync(int accountId, bool currentYearOnly)
+ {
+ var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
+ return GetEntriesAsync(accountId, year);
+ }
+
+ public async Task> GetEntriesAsync(int accountId, int? year)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
- if (currentYearOnly)
- query = query.Where(e => e.Date.Year == DateTime.Now.Year);
+ if (year.HasValue)
+ query = query.Where(e => e.Date.Year == year.Value);
var entries = await query
.OrderBy(e => e.Date)
@@ -59,6 +65,18 @@ public class EntryService : IEntryService
}).ToList();
}
+ public async Task> 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 CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
await using var db = await _dbFactory.CreateDbContextAsync();
diff --git a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
index 099c4cf..a652a34 100644
--- a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
+++ b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
@@ -27,19 +27,25 @@ public class PdfStatementService : IPdfStatementService
_settingsService = settingsService;
}
- public async Task GenerateStatementAsync(int accountId, bool currentYearOnly)
+ public Task GenerateStatementAsync(int accountId, bool currentYearOnly)
+ {
+ var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
+ return GenerateStatementAsync(accountId, year);
+ }
+
+ public async Task GenerateStatementAsync(int accountId, int? year)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
- var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly);
- var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId);
+ var entries = await _entryService.GetEntriesAsync(accountId, year);
+ var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year);
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
- var title = currentYearOnly
- ? $"{account.Name} – Auszug {DateTime.Now.Year}"
+ var title = year.HasValue
+ ? $"{account.Name} – Auszug {year.Value}"
: $"{account.Name} – Gesamtauszug";
var document = Document.Create(container =>
@@ -61,12 +67,6 @@ public class PdfStatementService : IPdfStatementService
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 =>
{
table.ColumnsDefinition(columns =>
@@ -114,9 +114,16 @@ public class PdfStatementService : IPdfStatementService
col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(10).Column(summaryCol =>
{
- SummaryRow(summaryCol, "Übertrag:", balance.CarryoverBalance);
- SummaryRow(summaryCol, "Einnahmen gesamt:", balance.TotalIncome);
- SummaryRow(summaryCol, "Ausgaben gesamt:", -balance.TotalExpense);
+ var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome;
+ var expenseValue = year.HasValue ? balance.CurrentYearExpense : 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 =>
{
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
@@ -175,6 +182,11 @@ public class PdfStatementService : IPdfStatementService
g => g.Key,
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 processedTransferIds = new HashSet();
@@ -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 =>
@@ -352,6 +374,31 @@ public class PdfStatementService : IPdfStatementService
.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)
{
var parts = displayId.Split('-');