K17 fertig.

Erweitert: src/Koogle.Application/Services/CashBookExportService.cs

  PDF-Struktur (QuestPDF 2025.12.1):
  - Header: Titel, Vereinsname, Berichtszeitraum
  - Zusammenfassung: Salden-Tabelle (Anfang/Einnahmen/Ausgaben/End)
  - Kategorien: Einnahmen + Ausgaben mit farbigen Tabellen
  - Footer: Erstelldatum + Seitenzahlen
This commit is contained in:
beo3000 2026-01-03 22:03:17 +01:00
parent 5bd0e19f06
commit ea237183f9
3 changed files with 158 additions and 3 deletions

View File

@ -1715,7 +1715,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
| ✓ | K14 | Web | Categories UI | 2 |
| ✓ | K15 | Web | Reports UI | 2 |
| ✓ | K16 | Application | Excel Export (ClosedXML) | 3 |
| | K17 | Application | PDF Export (QuestPDF) | 1 |
| | K17 | Application | PDF Export (QuestPDF) | 1 |
| ☐ | K18 | Web | Export Controller | 1 |
| ☐ | K19 | Web | Membership Fees Feature | 2 |
| ☐ | K20 | Web | Club Settings Extension | 3 |

View File

@ -10,6 +10,7 @@
<PackageReference Include="AutoMapper" Version="15.1.0" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="QuestPDF" Version="2025.12.1" />
</ItemGroup>
<ItemGroup>

View File

@ -2,6 +2,9 @@ using ClosedXML.Excel;
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Koogle.Application.Services;
@ -10,6 +13,12 @@ namespace Koogle.Application.Services;
/// </summary>
public class CashBookExportService : ICashBookExportService
{
static CashBookExportService()
{
// QuestPDF Community License
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToExcelAsync(CashBookReportDto report, string clubName)
{
@ -27,8 +36,153 @@ public class CashBookExportService : ICashBookExportService
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(CashBookReportDto report, string clubName)
{
// Will be implemented in K17 with QuestPDF
throw new NotImplementedException("PDF export will be available in a future version.");
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(10));
page.Header().Element(c => ComposeHeader(c, report, clubName));
page.Content().Element(c => ComposeContent(c, report));
page.Footer().Element(ComposeFooter);
});
});
var bytes = document.GeneratePdf();
return Task.FromResult(bytes);
}
private static void ComposeHeader(IContainer container, CashBookReportDto report, string clubName)
{
container.Column(column =>
{
column.Item().Text("Kassenbericht").FontSize(20).Bold();
column.Item().Text(clubName).FontSize(14);
column.Item().Text($"Zeitraum: {report.ReportStart:dd.MM.yyyy} - {report.ReportEnd:dd.MM.yyyy}").FontSize(11);
column.Item().PaddingBottom(10);
});
}
private static void ComposeContent(IContainer container, CashBookReportDto report)
{
container.Column(column =>
{
// Summary section
column.Item().Text("Zusammenfassung").FontSize(14).Bold();
column.Item().PaddingVertical(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2);
columns.RelativeColumn(1);
});
table.Cell().Text("Anfangssaldo");
table.Cell().AlignRight().Text(report.OpeningBalance.ToString("C"));
table.Cell().Text("Einnahmen");
table.Cell().AlignRight().Text($"+{report.TotalIncome:C}").FontColor(Colors.Green.Darken2);
table.Cell().Text("Ausgaben");
table.Cell().AlignRight().Text($"-{report.TotalExpense:C}").FontColor(Colors.Red.Darken2);
table.Cell().Text("Differenz");
table.Cell().AlignRight().Text((report.TotalIncome - report.TotalExpense).ToString("C"));
table.Cell().Border(1).BorderColor(Colors.Grey.Lighten1).Padding(2).Text("Endsaldo").Bold();
table.Cell().Border(1).BorderColor(Colors.Grey.Lighten1).Padding(2).AlignRight().Text(report.ClosingBalance.ToString("C")).Bold();
});
column.Item().PaddingTop(15);
// Income by category
if (report.IncomeByCategory.Count > 0)
{
column.Item().Text("Einnahmen nach Kategorie").FontSize(12).Bold();
column.Item().PaddingVertical(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
});
table.Header(header =>
{
header.Cell().Background(Colors.Green.Lighten4).Padding(3).Text("Kategorie").Bold();
header.Cell().Background(Colors.Green.Lighten4).Padding(3).AlignRight().Text("Anzahl").Bold();
header.Cell().Background(Colors.Green.Lighten4).Padding(3).AlignRight().Text("Summe").Bold();
});
foreach (var cat in report.IncomeByCategory.OrderByDescending(c => c.Total))
{
table.Cell().Padding(2).Text(cat.CategoryName);
table.Cell().Padding(2).AlignRight().Text(cat.Count.ToString());
table.Cell().Padding(2).AlignRight().Text(cat.Total.ToString("C"));
}
table.Cell().Border(1).Padding(2).Text("Gesamt").Bold();
table.Cell().Border(1).Padding(2).AlignRight().Text(report.IncomeByCategory.Sum(c => c.Count).ToString()).Bold();
table.Cell().Border(1).Padding(2).AlignRight().Text(report.TotalIncome.ToString("C")).Bold();
});
}
column.Item().PaddingTop(10);
// Expense by category
if (report.ExpenseByCategory.Count > 0)
{
column.Item().Text("Ausgaben nach Kategorie").FontSize(12).Bold();
column.Item().PaddingVertical(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
});
table.Header(header =>
{
header.Cell().Background(Colors.Red.Lighten4).Padding(3).Text("Kategorie").Bold();
header.Cell().Background(Colors.Red.Lighten4).Padding(3).AlignRight().Text("Anzahl").Bold();
header.Cell().Background(Colors.Red.Lighten4).Padding(3).AlignRight().Text("Summe").Bold();
});
foreach (var cat in report.ExpenseByCategory.OrderByDescending(c => c.Total))
{
table.Cell().Padding(2).Text(cat.CategoryName);
table.Cell().Padding(2).AlignRight().Text(cat.Count.ToString());
table.Cell().Padding(2).AlignRight().Text(cat.Total.ToString("C"));
}
table.Cell().Border(1).Padding(2).Text("Gesamt").Bold();
table.Cell().Border(1).Padding(2).AlignRight().Text(report.ExpenseByCategory.Sum(c => c.Count).ToString()).Bold();
table.Cell().Border(1).Padding(2).AlignRight().Text(report.TotalExpense.ToString("C")).Bold();
});
}
column.Item().PaddingTop(15);
// Statistics
column.Item().Text($"Anzahl Buchungen: {report.Entries.Count}").FontSize(10);
});
}
private static void ComposeFooter(IContainer container)
{
container.AlignCenter().Text(text =>
{
text.Span("Erstellt am ");
text.Span(DateTime.Now.ToString("dd.MM.yyyy HH:mm"));
text.Span(" - Seite ");
text.CurrentPageNumber();
text.Span(" von ");
text.TotalPages();
});
}
private static void CreateSummarySheet(XLWorkbook workbook, CashBookReportDto report, string clubName)