This commit is contained in:
MarcWieland
2026-06-26 23:17:08 +02:00
parent 4a69095f30
commit 62e58fc9ad
8 changed files with 625 additions and 228 deletions
+193 -164
View File
@@ -1,6 +1,6 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -10,12 +10,6 @@ namespace timetracker.Server.Services
{
public class PdfGeneratorService
{
public PdfGeneratorService()
{
// Configure QuestPDF community license
QuestPDF.Settings.License = LicenseType.Community;
}
public byte[] GenerateTimesheetPdf(
User user,
Tenant? tenant,
@@ -26,57 +20,76 @@ namespace timetracker.Server.Services
List<VacationDay> vacationDays,
List<PublicHoliday> holidays)
{
var document = Document.Create(container =>
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null)
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(2, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily(Fonts.Arial));
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new PdfFontResolver();
}
page.Header().Element(c => ComposeHeader(c, user, tenant, year, month));
page.Content().Element(c => ComposeContent(c, user, settings, year, month, workDays, vacationDays, holidays));
page.Footer().Element(ComposeFooter);
});
});
var document = new Document();
document.Info.Title = "Stundenzettel";
document.Info.Author = tenant?.Name ?? "Timetracker";
var section = document.AddSection();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = "2cm";
section.PageSetup.BottomMargin = "2cm";
section.PageSetup.LeftMargin = "2cm";
section.PageSetup.RightMargin = "2cm";
return document.GeneratePdf();
var style = document.Styles["Normal"];
if (style != null && style.Font != null)
{
style.Font.Name = "OpenSans";
style.Font.Size = 10;
}
ComposeHeader(section, user, tenant, year, month);
ComposeContent(section, document, user, settings, year, month, workDays, vacationDays, holidays);
ComposeFooter(section);
var pdfRenderer = new PdfDocumentRenderer()
{
Document = document
};
pdfRenderer.RenderDocument();
using var stream = new System.IO.MemoryStream();
pdfRenderer.PdfDocument.Save(stream, false);
return stream.ToArray();
}
private void ComposeHeader(IContainer container, User user, Tenant? tenant, int year, int month)
private void ComposeHeader(Section section, User user, Tenant? tenant, int year, int month)
{
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy", new System.Globalization.CultureInfo("de-DE"));
var companyName = tenant?.Name ?? "Hauptdomain";
container.Row(row =>
{
row.RelativeItem().Column(column =>
{
column.Item().Text($"Stundenzettel - {monthName}").FontSize(20).SemiBold().FontColor(Colors.Blue.Darken2);
column.Item().Text($"Mitarbeiter: {user.Username}").FontSize(14).SemiBold();
column.Item().PaddingTop(2).Text($"Firma: {companyName}").FontSize(11).FontColor(Colors.Grey.Darken2);
});
});
var paragraph = section.AddParagraph($"Stundenzettel - {monthName}");
paragraph.Format.Font.Size = 20;
paragraph.Format.Font.Bold = true;
paragraph.Format.Font.Color = Colors.DarkBlue;
paragraph.Format.SpaceAfter = "0.2cm";
paragraph = section.AddParagraph($"Mitarbeiter: {user.Username}");
paragraph.Format.Font.Size = 14;
paragraph.Format.Font.Bold = true;
paragraph.Format.SpaceAfter = "0.1cm";
paragraph = section.AddParagraph($"Firma: {companyName}");
paragraph.Format.Font.Size = 11;
paragraph.Format.Font.Color = Colors.DarkGray;
paragraph.Format.SpaceAfter = "1cm";
}
private void ComposeContent(IContainer container, User user, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays)
private void ComposeContent(Section section, Document document, User user, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays)
{
TimeSpan totalNet = TimeSpan.Zero;
TimeSpan totalTarget = TimeSpan.Zero;
TimeSpan totalOvertime = TimeSpan.Zero;
ComposeTable(section, document, settings, year, month, workDays, vacationDays, holidays, out var totalNet, out var totalTarget, out var totalOvertime);
ComposeSummary(section, totalNet, totalTarget, totalOvertime);
container.PaddingVertical(1, Unit.Centimetre).Column(column =>
{
column.Item().Element(c => ComposeTable(c, settings, year, month, workDays, vacationDays, holidays, out totalNet, out totalTarget, out totalOvertime));
column.Item().PaddingTop(25).Element(c => ComposeSummary(c, totalNet, totalTarget, totalOvertime));
column.Item().PaddingTop(50).Element(ComposeSignatures);
});
ComposeSignatures(section);
}
private void ComposeTable(IContainer container, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays, out TimeSpan totalNetOut, out TimeSpan totalTargetOut, out TimeSpan totalOvertimeOut)
private void ComposeTable(Section section, Document document, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays, out TimeSpan totalNetOut, out TimeSpan totalTargetOut, out TimeSpan totalOvertimeOut)
{
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacationDays.Select(v => v.Date).ToHashSet();
@@ -86,142 +99,158 @@ namespace timetracker.Server.Services
var totalTarget = TimeSpan.Zero;
var totalOvertime = TimeSpan.Zero;
container.Table(table =>
var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.LightGray;
// Define columns
table.AddColumn("2.5cm"); // Datum
table.AddColumn("1.5cm"); // Start
table.AddColumn("1.5cm"); // Ende
table.AddColumn("1.8cm"); // Pause
table.AddColumn("1.8cm"); // Netto
table.AddColumn("1.8cm"); // Gleitzeit
table.AddColumn("6.1cm"); // Info
// Header Row
var row = table.AddRow();
row.HeadingFormat = true;
row.Format.Font.Bold = true;
row.Shading.Color = Colors.White;
row.Borders.Bottom.Width = 1;
row.Borders.Bottom.Color = Colors.Black;
row.Cells[0].AddParagraph("Datum");
row.Cells[1].AddParagraph("Start").Format.Alignment = ParagraphAlignment.Right;
row.Cells[2].AddParagraph("Ende").Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].AddParagraph("Pause").Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].AddParagraph("Netto").Format.Alignment = ParagraphAlignment.Right;
row.Cells[5].AddParagraph("Gleitzeit").Format.Alignment = ParagraphAlignment.Right;
row.Cells[6].AddParagraph("Info/Status");
for (int day = 1; day <= daysInMonth; day++)
{
table.ColumnsDefinition(columns =>
var date = new DateOnly(year, month, day);
var wd = workDays.FirstOrDefault(w => w.Date == date);
var isWorkDay = settings.IsWorkDay(date.DayOfWeek);
var isVacation = vacationSet.Contains(date);
var holidayName = holidayMap.GetValueOrDefault(date);
TimeSpan? net = null;
TimeSpan breakSum = TimeSpan.Zero;
if (wd?.StartTime != null && wd.EndTime != null)
{
columns.ConstantColumn(70); // Datum
columns.ConstantColumn(50); // Start
columns.ConstantColumn(50); // Ende
columns.ConstantColumn(60); // Pause
columns.ConstantColumn(60); // Netto
columns.ConstantColumn(60); // Gleitzeit
columns.RelativeColumn(); // Info
});
table.Header(header =>
{
header.Cell().Element(CellStyle).Text("Datum");
header.Cell().Element(CellStyle).AlignRight().Text("Start");
header.Cell().Element(CellStyle).AlignRight().Text("Ende");
header.Cell().Element(CellStyle).AlignRight().Text("Pause");
header.Cell().Element(CellStyle).AlignRight().Text("Netto");
header.Cell().Element(CellStyle).AlignRight().Text("Gleitzeit");
header.Cell().Element(CellStyle).Text("Info/Status");
static IContainer CellStyle(IContainer container)
{
return container.DefaultTextStyle(x => x.SemiBold()).PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Black);
}
});
for (int day = 1; day <= daysInMonth; day++)
{
var date = new DateOnly(year, month, day);
var wd = workDays.FirstOrDefault(w => w.Date == date);
var isWorkDay = settings.IsWorkDay(date.DayOfWeek);
var isVacation = vacationSet.Contains(date);
var holidayName = holidayMap.GetValueOrDefault(date);
TimeSpan? net = null;
TimeSpan breakSum = TimeSpan.Zero;
if (wd?.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
breakSum = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakSum > minBreak ? breakSum : minBreak;
net = gross - finalBreak;
if (net < TimeSpan.Zero) net = null;
}
var target = isWorkDay ? TimeSpan.FromHours(settings.DailyTargetHours) : TimeSpan.Zero;
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
if (net.HasValue) totalNet += net.Value;
if (isWorkDay) totalTarget += target;
if (isWorkDay && net.HasValue) totalOvertime += overtime;
string status = "";
if (!string.IsNullOrEmpty(holidayName)) status = holidayName;
else if (isVacation) status = "Urlaub";
else if (!isWorkDay) status = "Wochenende";
var bgColor = Colors.White;
if (!isWorkDay || !string.IsNullOrEmpty(holidayName)) bgColor = Colors.Grey.Lighten4;
if (isVacation) bgColor = Colors.Orange.Lighten4;
table.Cell().Element(c => CellStyle(c, bgColor)).Text(date.ToString("dd.MM.yyyy"));
table.Cell().Element(c => CellStyle(c, bgColor)).AlignRight().Text(wd?.StartTime?.ToString(@"HH\:mm") ?? "-");
table.Cell().Element(c => CellStyle(c, bgColor)).AlignRight().Text(wd?.EndTime?.ToString(@"HH\:mm") ?? "-");
table.Cell().Element(c => CellStyle(c, bgColor)).AlignRight().Text(net.HasValue ? FormatTs(breakSum) : "-");
table.Cell().Element(c => CellStyle(c, bgColor)).AlignRight().Text(net.HasValue ? FormatTs(net.Value) : "-");
var overtimeColor = overtime < TimeSpan.Zero ? Colors.Red.Darken2 : Colors.Green.Darken2;
table.Cell().Element(c => CellStyle(c, bgColor)).AlignRight().Text(net.HasValue ? FormatTs(overtime, true) : "-").FontColor(net.HasValue && overtime != TimeSpan.Zero ? overtimeColor : Colors.Black);
table.Cell().Element(c => CellStyle(c, bgColor)).Text(status).FontColor(Colors.Grey.Darken2);
static IContainer CellStyle(IContainer container, string bgColor)
{
return container.BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Background(bgColor).PaddingVertical(3).PaddingHorizontal(2);
}
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
breakSum = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakSum > minBreak ? breakSum : minBreak;
net = gross - finalBreak;
if (net < TimeSpan.Zero) net = null;
}
});
var target = isWorkDay ? TimeSpan.FromHours(settings.DailyTargetHours) : TimeSpan.Zero;
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
if (net.HasValue) totalNet += net.Value;
if (isWorkDay) totalTarget += target;
if (isWorkDay && net.HasValue) totalOvertime += overtime;
string status = "";
if (!string.IsNullOrEmpty(holidayName)) status = holidayName;
else if (isVacation) status = "Urlaub";
else if (!isWorkDay) status = "Wochenende";
var bgColor = Colors.White;
if (!isWorkDay || !string.IsNullOrEmpty(holidayName)) bgColor = new Color(245, 245, 245); // Light Gray
if (isVacation) bgColor = new Color(255, 236, 179); // Light Orange
row = table.AddRow();
row.Shading.Color = bgColor;
row.Cells[0].AddParagraph(date.ToString("dd.MM.yyyy"));
row.Cells[1].AddParagraph(wd?.StartTime?.ToString(@"HH\:mm") ?? "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[2].AddParagraph(wd?.EndTime?.ToString(@"HH\:mm") ?? "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].AddParagraph(net.HasValue ? FormatTs(breakSum) : "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].AddParagraph(net.HasValue ? FormatTs(net.Value) : "-").Format.Alignment = ParagraphAlignment.Right;
var overtimePara = row.Cells[5].AddParagraph(net.HasValue ? FormatTs(overtime, true) : "-");
overtimePara.Format.Alignment = ParagraphAlignment.Right;
if (net.HasValue && overtime != TimeSpan.Zero)
{
overtimePara.Format.Font.Color = overtime < TimeSpan.Zero ? Colors.DarkRed : Colors.DarkGreen;
}
var statusPara = row.Cells[6].AddParagraph(status);
statusPara.Format.Font.Color = Colors.DarkGray;
}
totalNetOut = totalNet;
totalTargetOut = totalTarget;
totalOvertimeOut = totalOvertime;
}
private void ComposeSummary(IContainer container, TimeSpan totalNet, TimeSpan totalTarget, TimeSpan totalOvertime)
private void ComposeSummary(Section section, TimeSpan totalNet, TimeSpan totalTarget, TimeSpan totalOvertime)
{
container.Background(Colors.Grey.Lighten4).Padding(10).Row(row =>
var paragraph = section.AddParagraph();
paragraph.Format.SpaceBefore = "1cm";
var table = section.AddTable();
table.Shading.Color = new Color(245, 245, 245);
table.AddColumn("4cm");
table.AddColumn("4cm");
var row = table.AddRow();
row.Cells[0].AddParagraph("Zusammenfassung").Format.Font.Bold = true;
row.Cells[0].MergeRight = 1;
row = table.AddRow();
row.Cells[0].AddParagraph("Soll-Stunden:");
row.Cells[1].AddParagraph(FormatTs(totalTarget));
row = table.AddRow();
row.Cells[0].AddParagraph("Ist-Stunden:");
row.Cells[1].AddParagraph(FormatTs(totalNet));
row = table.AddRow();
row.Cells[0].AddParagraph("Saldo:");
var saldoPara = row.Cells[1].AddParagraph(FormatTs(totalOvertime, true));
saldoPara.Format.Font.Bold = true;
if (totalOvertime != TimeSpan.Zero)
{
row.RelativeItem().Column(column =>
{
column.Item().Text("Zusammenfassung").SemiBold();
column.Item().Text($"Soll-Stunden: {FormatTs(totalTarget)}");
column.Item().Text($"Ist-Stunden: {FormatTs(totalNet)}");
var overtimeColor = totalOvertime < TimeSpan.Zero ? Colors.Red.Darken2 : Colors.Green.Darken2;
column.Item().Text($"Saldo: {FormatTs(totalOvertime, true)}").SemiBold().FontColor(totalOvertime != TimeSpan.Zero ? overtimeColor : Colors.Black);
});
});
saldoPara.Format.Font.Color = totalOvertime < TimeSpan.Zero ? Colors.DarkRed : Colors.DarkGreen;
}
}
private void ComposeSignatures(IContainer container)
private void ComposeSignatures(Section section)
{
container.Row(row =>
{
row.RelativeItem().Column(column =>
{
column.Item().LineHorizontal(1);
column.Item().PaddingTop(5).Text("Datum, Unterschrift Mitarbeiter").FontSize(10);
});
row.ConstantItem(50); // space
row.RelativeItem().Column(column =>
{
column.Item().LineHorizontal(1);
column.Item().PaddingTop(5).Text("Datum, Unterschrift Arbeitgeber").FontSize(10);
});
});
var table = section.AddTable();
table.Format.SpaceBefore = "2cm";
table.AddColumn("7cm");
table.AddColumn("2cm");
table.AddColumn("7cm");
var row = table.AddRow();
row.Cells[0].Borders.Bottom.Width = 1;
row.Cells[2].Borders.Bottom.Width = 1;
row = table.AddRow();
row.Cells[0].AddParagraph("Datum, Unterschrift Mitarbeiter").Format.Font.Size = 8;
row.Cells[2].AddParagraph("Datum, Unterschrift Arbeitgeber").Format.Font.Size = 8;
}
private void ComposeFooter(IContainer container)
private void ComposeFooter(Section section)
{
container.AlignCenter().Text(x =>
{
x.Span("Seite ");
x.CurrentPageNumber();
x.Span(" von ");
x.TotalPages();
});
var paragraph = section.Footers.Primary.AddParagraph();
paragraph.AddText("Seite ");
paragraph.AddPageField();
paragraph.AddText(" von ");
paragraph.AddNumPagesField();
paragraph.Format.Alignment = ParagraphAlignment.Center;
}
private static string FormatTs(TimeSpan ts, bool sign = false)