diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index cdbdfd5..0c79f53 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -39,8 +39,8 @@ PDF | - @@ -56,21 +56,21 @@
Einnahmen -
@FormatAmount(showCurrentYearOnly ? balance.CurrentYearIncome : balance.TotalIncome)
+
@FormatAmount(selectedYear.HasValue ? balance.CurrentYearIncome : balance.TotalIncome)
Ausgaben -
@FormatAmount(showCurrentYearOnly ? balance.CurrentYearExpense : balance.TotalExpense)
+
@FormatAmount(selectedYear.HasValue ? balance.CurrentYearExpense : balance.TotalExpense)
- @if (showCurrentYearOnly) + @if (selectedYear.HasValue) {
- Ü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('-');