diff --git a/.vscode/launch.json b/.vscode/launch.json
index 712bc83..329ea7b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -6,7 +6,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
- "program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.dll",
+ "program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.exe",
"args": [],
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
"stopAtEntry": false,
diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor
index 8a40306..2358eca 100644
--- a/src/Duempelkas.App/Pages/Dashboard.razor
+++ b/src/Duempelkas.App/Pages/Dashboard.razor
@@ -2,8 +2,11 @@
@inject IAccountService AccountService
@inject IBackupService BackupService
@inject ISettingsService SettingsService
+@inject IPdfStatementService PdfStatementService
+@inject IFileSaveService FileSaveService
@inject IJSRuntime JsRuntime
@inject NavigationManager NavigationManager
+@using System.Globalization
@@ -29,8 +32,22 @@
+ |
+
+ @if (accounts != null && accounts.Any())
+ {
+
+ Summe aller Konten:
+
+ @FormatCurrency(TotalClubBalance)
+
+
+ }
+
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
@@ -68,6 +85,18 @@
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
}
+@if (!string.IsNullOrWhiteSpace(savedPdfPath))
+{
+
+}
+
@code {
private List
? accounts;
private bool showAddAccount;
@@ -75,7 +104,9 @@
private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
+ private string? savedPdfPath;
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
+ private decimal TotalClubBalance => accounts?.Sum(a => a.TotalBalance) ?? 0m;
protected override async Task OnInitializedAsync()
{
@@ -136,9 +167,35 @@
}
+ private async Task HandleDashboardExportAsync()
+ {
+ var pdf = await PdfStatementService.GenerateDashboardStatementAsync();
+ savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf");
+ }
+
+ private async Task HandleOpenSavedPdf()
+ {
+ if (!string.IsNullOrWhiteSpace(savedPdfPath))
+ {
+ await FileSaveService.OpenFileAsync(savedPdfPath);
+ }
+
+ savedPdfPath = null;
+ }
+
+ private void CancelOpenSavedPdf()
+ {
+ savedPdfPath = null;
+ }
+
private void SetOperationMessage(string message, bool success)
{
operationMessage = message;
operationMessageClass = success ? "alert-success" : "alert-danger";
}
+
+ private static string FormatCurrency(decimal amount)
+ {
+ return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
+ }
}
diff --git a/src/Duempelkas.App/Services/IPdfStatementService.cs b/src/Duempelkas.App/Services/IPdfStatementService.cs
index de69107..7f320b2 100644
--- a/src/Duempelkas.App/Services/IPdfStatementService.cs
+++ b/src/Duempelkas.App/Services/IPdfStatementService.cs
@@ -3,4 +3,5 @@ namespace Duempelkas.App.Services;
public interface IPdfStatementService
{
Task GenerateStatementAsync(int accountId, bool currentYearOnly);
+ Task GenerateDashboardStatementAsync();
}
diff --git a/src/Duempelkas.Desktop/Assets/app-icon.ico b/src/Duempelkas.Desktop/Assets/app-icon.ico
new file mode 100644
index 0000000..ff337f2
Binary files /dev/null and b/src/Duempelkas.Desktop/Assets/app-icon.ico differ
diff --git a/src/Duempelkas.Desktop/Assets/app-icon.png b/src/Duempelkas.Desktop/Assets/app-icon.png
new file mode 100644
index 0000000..9e166bf
Binary files /dev/null and b/src/Duempelkas.Desktop/Assets/app-icon.png differ
diff --git a/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj b/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj
index 7289ce5..56ab6e0 100644
--- a/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj
+++ b/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj
@@ -3,6 +3,7 @@
net10.0
Exe
WinExe
+ Assets\app-icon.ico
enable
enable
$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)
@@ -23,6 +24,9 @@
PreserveNewest
+
+ PreserveNewest
+
diff --git a/src/Duempelkas.Desktop/Program.cs b/src/Duempelkas.Desktop/Program.cs
index ead9972..ca31baa 100644
--- a/src/Duempelkas.Desktop/Program.cs
+++ b/src/Duempelkas.Desktop/Program.cs
@@ -3,18 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Photino.Blazor;
+using System.Runtime.InteropServices;
namespace Duempelkas.Desktop;
class Program
{
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
+
[STAThread]
static void Main(string[] args)
{
+ if (OperatingSystem.IsWindows())
+ {
+ // Ensure Windows taskbar does not group this process under generic dotnet host identity.
+ SetCurrentProcessExplicitAppUserModelID("Duempelkas.Desktop");
+ }
+
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
- var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
- var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
+ // Use a per-user writable DB location so debugging via dotnet host works reliably.
+ var appDataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "Duempelkas");
+ Directory.CreateDirectory(appDataDir);
+
+ var dbPath = Path.Combine(appDataDir, "duempelkas.db");
+
+ // One-time compatibility: keep existing data if older versions stored DB next to the app executable.
+ var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
+ var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
+ if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
+ {
+ try
+ {
+ File.Copy(legacyDbPath, dbPath);
+ }
+ catch
+ {
+ // Ignore copy errors and continue with a fresh DB.
+ }
+ }
+
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
appBuilder.RootComponents.Add("app");
@@ -23,6 +54,13 @@ class Program
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
+ // Force the native window icon for title bar + taskbar representation.
+ var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app-icon.ico");
+ if (File.Exists(iconPath))
+ {
+ app.MainWindow.SetIconFile(iconPath);
+ }
+
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
diff --git a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
index 97fc066..099c4cf 100644
--- a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
+++ b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
+using Duempelkas.Domain.Entities;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -138,6 +139,182 @@ public class PdfStatementService : IPdfStatementService
return document.GeneratePdf();
}
+ public async Task GenerateDashboardStatementAsync()
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync();
+
+ var accounts = await db.Accounts
+ .OrderBy(a => a.Name)
+ .ToListAsync();
+
+ var accountById = accounts.ToDictionary(a => a.Id);
+ var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
+
+ var transfers = await db.TransferLinks
+ .Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
+ .Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
+ .ToListAsync();
+
+ var transferByEntryId = new Dictionary();
+ foreach (var transfer in transfers)
+ {
+ transferByEntryId[transfer.SourceEntryId] = transfer;
+ transferByEntryId[transfer.TargetEntryId] = transfer;
+ }
+
+ var entries = await db.Entries
+ .Include(e => e.Account)
+ .Where(e => !e.IsDeleted)
+ .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));
+
+ var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
+
+ var processedTransferIds = new HashSet();
+ var rows = new List();
+
+ foreach (var entry in entries)
+ {
+ if (!transferByEntryId.TryGetValue(entry.Id, out var transfer))
+ {
+ rows.Add(new DashboardStatementRow(
+ entry.DisplayId,
+ entry.Date,
+ entry.Title,
+ new Dictionary
+ {
+ [entry.AccountId] = entry.Type == EntryType.Income ? entry.Amount : -entry.Amount
+ }));
+ continue;
+ }
+
+ if (!processedTransferIds.Add(transfer.Id))
+ continue;
+
+ var source = transfer.SourceEntry;
+ var target = transfer.TargetEntry;
+
+ if (source.IsDeleted || target.IsDeleted)
+ continue;
+
+ var transferTitle = $"{source.Title} ({source.Account.Name} -> {target.Account.Name})";
+
+ rows.Add(new DashboardStatementRow(
+ source.DisplayId,
+ source.Date,
+ transferTitle,
+ new Dictionary
+ {
+ [source.AccountId] = -source.Amount,
+ [target.AccountId] = target.Amount
+ }));
+ }
+
+ rows = rows
+ .OrderBy(r => ParseDisplayIdYear(r.DisplayId))
+ .ThenBy(r => ParseDisplayIdSequence(r.DisplayId))
+ .ThenBy(r => r.Date)
+ .ThenBy(r => r.Title)
+ .ToList();
+
+ var document = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Size(PageSizes.A4.Landscape());
+ page.MarginHorizontal(24);
+ page.MarginVertical(20);
+ page.DefaultTextStyle(x => x.FontSize(9));
+
+ page.Header().Column(col =>
+ {
+ col.Item().Text(clubName).Bold().FontSize(18);
+ col.Item().Text("Übersicht aller Konten").FontSize(13).FontColor(Colors.Grey.Darken1);
+ col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
+ .FontSize(10)
+ .SemiBold()
+ .FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
+ col.Item().PaddingTop(4).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
+ col.Item().PaddingTop(8).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
+ });
+
+ page.Content().PaddingTop(10).Column(content =>
+ {
+ content.Item().Table(table =>
+ {
+ table.ColumnsDefinition(columns =>
+ {
+ columns.ConstantColumn(70);
+ columns.ConstantColumn(70);
+ columns.RelativeColumn(2);
+ foreach (var _ in accounts)
+ columns.RelativeColumn();
+ });
+
+ table.Header(header =>
+ {
+ header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
+ .PaddingBottom(4).Text("LFDNR").SemiBold();
+ header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
+ .PaddingBottom(4).Text("Datum").SemiBold();
+ header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
+ .PaddingBottom(4).Text("Titel").SemiBold();
+ foreach (var account in accounts)
+ {
+ header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
+ .PaddingBottom(4).AlignRight().Text(account.Name).SemiBold();
+ }
+ });
+
+ foreach (var row in rows)
+ {
+ BodyCell(table, row.DisplayId);
+ BodyCell(table, row.Date.ToString("dd.MM.yyyy"));
+ BodyCell(table, row.Title);
+
+ foreach (var account in accounts)
+ {
+ if (row.AmountByAccountId.TryGetValue(account.Id, out var amount))
+ {
+ BodyAmountCell(table, amount);
+ }
+ else
+ {
+ BodyCell(table, string.Empty, alignRight: true);
+ }
+ }
+ }
+ });
+
+ content.Item().PaddingTop(8).AlignRight().Text(text =>
+ {
+ text.Span("Gesamtvermögen: ").SemiBold();
+ text.Span(FormatCurrency(totalClubBalance))
+ .SemiBold()
+ .FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
+ });
+ });
+
+ page.Footer().AlignCenter().Text(text =>
+ {
+ text.Span("Seite ");
+ text.CurrentPageNumber();
+ text.Span(" von ");
+ text.TotalPages();
+ });
+ });
+ });
+
+ return document.GeneratePdf();
+ }
+
private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
{
col.Item().PaddingVertical(2).Row(row =>
@@ -152,4 +329,40 @@ public class PdfStatementService : IPdfStatementService
{
return amount.ToString("N2", DeLocale) + " €";
}
+
+ private static void BodyCell(TableDescriptor table, string text, bool alignRight = false)
+ {
+ var cell = table.Cell()
+ .BorderBottom(0.5f)
+ .BorderColor(Colors.Grey.Lighten2)
+ .PaddingVertical(3);
+
+ var content = alignRight ? cell.AlignRight() : cell;
+ content.Text(text);
+ }
+
+ private static void BodyAmountCell(TableDescriptor table, decimal amount)
+ {
+ table.Cell()
+ .BorderBottom(0.5f)
+ .BorderColor(Colors.Grey.Lighten2)
+ .PaddingVertical(3)
+ .AlignRight()
+ .Text($"{(amount >= 0 ? "+" : "−")}{FormatCurrency(Math.Abs(amount))}")
+ .FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
+ }
+
+ private static int ParseDisplayIdYear(string displayId)
+ {
+ var parts = displayId.Split('-');
+ return parts.Length == 2 && int.TryParse(parts[0], out var year) ? year : int.MaxValue;
+ }
+
+ private static int ParseDisplayIdSequence(string displayId)
+ {
+ var parts = displayId.Split('-');
+ return parts.Length == 2 && int.TryParse(parts[1], out var seq) ? seq : int.MaxValue;
+ }
+
+ private sealed record DashboardStatementRow(string DisplayId, DateTime Date, string Title, Dictionary AmountByAccountId);
}
diff --git a/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs b/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
new file mode 100644
index 0000000..10e6ca3
--- /dev/null
+++ b/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
@@ -0,0 +1,132 @@
+using Duempelkas.App.Services;
+using Duempelkas.Domain.Entities;
+using Duempelkas.Domain.Enums;
+using Duempelkas.Infrastructure.Persistence;
+using Duempelkas.Infrastructure.Services;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class DashboardPdfStatementServiceTests : IDisposable
+{
+ private readonly FinanceDbContext _db;
+ private readonly PdfStatementService _pdfService;
+ private readonly string _connectionString = $"Data Source=duempelkas-dashboard-pdf-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+
+ public DashboardPdfStatementServiceTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connectionString)
+ .Options;
+
+ _db = new FinanceDbContext(options);
+ _db.Database.OpenConnection();
+ _db.Database.EnsureCreated();
+
+ var dbFactory = new TestDbContextFactory(options);
+ var entryService = new EntryService(dbFactory);
+ var balanceQueryService = new BalanceQueryService(dbFactory);
+ var settingsService = new FixedSettingsService("Testverein");
+
+ _pdfService = new PdfStatementService(dbFactory, entryService, balanceQueryService, settingsService);
+ }
+
+ [Fact]
+ public async Task GenerateDashboardStatementAsync_WithBookingsAndTransfer_ReturnsPdf()
+ {
+ var barkasse = new Account { Name = "Barkasse" };
+ var girokonto = new Account { Name = "Girokonto" };
+ _db.Accounts.AddRange(barkasse, girokonto);
+ await _db.SaveChangesAsync();
+
+ _db.Entries.Add(new Entry
+ {
+ AccountId = barkasse.Id,
+ DisplayId = "2026-001",
+ Type = EntryType.Income,
+ Date = new DateTime(2026, 1, 12),
+ Title = "Einkuenfte Sommerfest",
+ Amount = 604.60m
+ });
+
+ var transferExpense = new Entry
+ {
+ AccountId = barkasse.Id,
+ DisplayId = "2026-002",
+ Type = EntryType.Expense,
+ Date = new DateTime(2026, 2, 16),
+ Title = "Einzahlung",
+ Amount = 600.00m
+ };
+
+ var transferIncome = new Entry
+ {
+ AccountId = girokonto.Id,
+ DisplayId = "2026-002",
+ Type = EntryType.Income,
+ Date = new DateTime(2026, 2, 16),
+ Title = "Einzahlung",
+ Amount = 600.00m
+ };
+
+ _db.Entries.AddRange(transferExpense, transferIncome);
+ await _db.SaveChangesAsync();
+
+ var transferLink = new TransferLink
+ {
+ SourceEntryId = transferExpense.Id,
+ TargetEntryId = transferIncome.Id,
+ Note = "Umbuchung"
+ };
+
+ _db.TransferLinks.Add(transferLink);
+ await _db.SaveChangesAsync();
+
+ transferExpense.TransferLinkId = transferLink.Id;
+ transferIncome.TransferLinkId = transferLink.Id;
+ await _db.SaveChangesAsync();
+
+ var pdf = await _pdfService.GenerateDashboardStatementAsync();
+
+ pdf.Should().NotBeNull();
+ pdf.Length.Should().BeGreaterThan(1000);
+ System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
+ }
+
+ public void Dispose()
+ {
+ _db.Database.CloseConnection();
+ _db.Dispose();
+ }
+
+ private sealed class TestDbContextFactory : IDbContextFactory
+ {
+ private readonly DbContextOptions _options;
+
+ public TestDbContextFactory(DbContextOptions options)
+ {
+ _options = options;
+ }
+
+ public FinanceDbContext CreateDbContext() => new(_options);
+
+ public Task CreateDbContextAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(new FinanceDbContext(_options));
+ }
+
+ private sealed class FixedSettingsService : ISettingsService
+ {
+ private readonly string? _clubName;
+
+ public FixedSettingsService(string? clubName)
+ {
+ _clubName = clubName;
+ }
+
+ public Task GetClubNameAsync() => Task.FromResult(_clubName);
+
+ public Task SetClubNameAsync(string? clubName) => Task.CompletedTask;
+ }
+}