diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor index 7af9882..827651a 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor @@ -27,7 +27,7 @@
+ disabled="@(!CanSave)"> @(EditEntry != null ? "Speichern" : "Hinzufügen")
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 0c79f53..7b08de9 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -39,8 +39,8 @@ PDF | - diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor index 37f2b26..64b893d 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor +++ b/src/Duempelkas.App/Pages/Dashboard.razor @@ -4,6 +4,7 @@ @inject ISettingsService SettingsService @inject IPdfStatementService PdfStatementService @inject IFileSaveService FileSaveService +@inject IEntryService EntryService @inject NavigationManager NavigationManager
@@ -34,6 +35,10 @@ + | +
@if (accounts != null && accounts.Any()) @@ -107,3 +112,29 @@ OnCancel="CancelRestoreConfirm" /> } +@if (showYearFilterDialog) +{ +
+
+
Filter auswählen
+

Wähle ein Jahr oder alle Buchungen.

+
+ + @foreach (var year in availableYears) + { + + } +
+
+ +
+
+
+} + diff --git a/src/Duempelkas.App/Pages/Dashboard.razor.cs b/src/Duempelkas.App/Pages/Dashboard.razor.cs index 2dbddc3..88c801c 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor.cs +++ b/src/Duempelkas.App/Pages/Dashboard.razor.cs @@ -11,6 +11,9 @@ public partial class Dashboard private bool showAddAccount; private bool showEditClubName; private bool showRestoreConfirm; + private bool showYearFilterDialog; + private int? selectedYear = DateTime.Now.Year; + private List availableYears = []; private string clubName = string.Empty; private string? operationMessage; private string operationMessageClass = "alert-info"; @@ -31,16 +34,22 @@ public partial class Dashboard protected override async Task OnInitializedAsync() { await LoadClubName(); - await LoadAccounts(); + await LoadAll(); } #endregion #region Data Loading + private async Task LoadAll() + { + await LoadAccounts(); + availableYears = await EntryService.GetAllEntryYearsAsync(); + } + private async Task LoadAccounts() { - accounts = await AccountService.GetAllAccountsAsync(); + accounts = await AccountService.GetAllAccountsAsync(selectedYear); } private async Task LoadClubName() @@ -56,7 +65,7 @@ public partial class Dashboard { await AccountService.CreateAccountAsync(name); showAddAccount = false; - await LoadAccounts(); + await LoadAll(); } private async Task HandleSaveClubName(string newName) @@ -104,8 +113,26 @@ public partial class Dashboard private async Task HandleDashboardExportAsync() { - var pdf = await PdfStatementService.GenerateDashboardStatementAsync(); - savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf"); + var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear); + 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() @@ -138,5 +165,7 @@ public partial class Dashboard return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €"; } + private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle"; + #endregion } diff --git a/src/Duempelkas.App/Services/IAccountService.cs b/src/Duempelkas.App/Services/IAccountService.cs index 3aa34a0..17c63ec 100644 --- a/src/Duempelkas.App/Services/IAccountService.cs +++ b/src/Duempelkas.App/Services/IAccountService.cs @@ -5,6 +5,7 @@ namespace Duempelkas.App.Services; public interface IAccountService { Task> GetAllAccountsAsync(); + Task> GetAllAccountsAsync(int? year); Task GetAccountAsync(int accountId); Task CreateAccountAsync(string name); Task RenameAccountAsync(int accountId, string newName); diff --git a/src/Duempelkas.App/Services/IEntryService.cs b/src/Duempelkas.App/Services/IEntryService.cs index 2cfb9f7..ba34859 100644 --- a/src/Duempelkas.App/Services/IEntryService.cs +++ b/src/Duempelkas.App/Services/IEntryService.cs @@ -8,6 +8,7 @@ public interface IEntryService Task> GetEntriesAsync(int accountId, int? year); Task> GetEntriesAsync(int accountId, bool currentYearOnly); Task> GetEntryYearsAsync(int accountId); + Task> GetAllEntryYearsAsync(); 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 2d03aa1..e4824df 100644 --- a/src/Duempelkas.App/Services/IPdfStatementService.cs +++ b/src/Duempelkas.App/Services/IPdfStatementService.cs @@ -5,4 +5,5 @@ public interface IPdfStatementService Task GenerateStatementAsync(int accountId, int? year); Task GenerateStatementAsync(int accountId, bool currentYearOnly); Task GenerateDashboardStatementAsync(); + Task GenerateDashboardStatementAsync(int? year); } diff --git a/src/Duempelkas.Infrastructure/Services/AccountService.cs b/src/Duempelkas.Infrastructure/Services/AccountService.cs index 105d493..7ff4d18 100644 --- a/src/Duempelkas.Infrastructure/Services/AccountService.cs +++ b/src/Duempelkas.Infrastructure/Services/AccountService.cs @@ -13,16 +13,60 @@ public class AccountService : IAccountService public AccountService(IDbContextFactory dbFactory) => _dbFactory = dbFactory; - public async Task> GetAllAccountsAsync() + public Task> GetAllAccountsAsync() + { + return GetAllAccountsAsync(null); + } + + public async Task> GetAllAccountsAsync(int? year) { await using var db = await _dbFactory.CreateDbContextAsync(); var accounts = await db.Accounts - .Include(a => a.Entries) .OrderBy(a => a.Name) .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 GetAccountAsync(int accountId) @@ -34,7 +78,11 @@ public class AccountService : IAccountService .FirstOrDefaultAsync(a => a.Id == accountId) ?? 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 CreateAccountAsync(string name) @@ -115,12 +163,4 @@ public class AccountService : IAccountService 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); - } } diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs index 62492a8..a27a4be 100644 --- a/src/Duempelkas.Infrastructure/Services/EntryService.cs +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -77,6 +77,17 @@ public class EntryService : IEntryService .ToListAsync(); } + public async Task> 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 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 a652a34..b3cd4d7 100644 --- a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs +++ b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs @@ -146,7 +146,12 @@ public class PdfStatementService : IPdfStatementService return document.GeneratePdf(); } - public async Task GenerateDashboardStatementAsync() + public Task GenerateDashboardStatementAsync() + { + return GenerateDashboardStatementAsync(null); + } + + public async Task GenerateDashboardStatementAsync(int? year) { await using var db = await _dbFactory.CreateDbContextAsync(); @@ -169,25 +174,65 @@ public class PdfStatementService : IPdfStatementService transferByEntryId[transfer.TargetEntryId] = transfer; } - var entries = await db.Entries + var entriesQuery = db.Entries .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) .ThenBy(e => e.CreatedUtc) .ToListAsync(); - var movementByAccountId = entries - .GroupBy(e => e.AccountId) - .ToDictionary( - g => g.Key, - g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); + Dictionary balanceByAccountId; + Dictionary? carryoverByAccountId = null; - var balanceByAccountId = accounts - .ToDictionary( - a => a.Id, - a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); + 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 totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); + 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 + .GroupBy(e => e.AccountId) + .ToDictionary( + g => g.Key, + g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); + + balanceByAccountId = accounts + .ToDictionary( + a => a.Id, + a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); + } + + var totalClubBalance = balanceByAccountId.Values.Sum(); var processedTransferIds = new HashSet(); var rows = new List(); @@ -248,7 +293,10 @@ public class PdfStatementService : IPdfStatementService page.Header().Column(col => { 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)}") .FontSize(10) .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) { BodyCell(table, row.DisplayId);