using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; using System; using System.Collections.Generic; using System.Linq; using timetracker.Shared; 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, AppSettings settings, int year, int month, List workDays, List vacationDays, List holidays) { var document = Document.Create(container => { 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)); 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); }); }); return document.GeneratePdf(); } private void ComposeHeader(IContainer container, 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); }); }); } private void ComposeContent(IContainer container, User user, AppSettings settings, int year, int month, List workDays, List vacationDays, List holidays) { TimeSpan totalNet = TimeSpan.Zero; TimeSpan totalTarget = TimeSpan.Zero; TimeSpan totalOvertime = TimeSpan.Zero; 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); }); } private void ComposeTable(IContainer container, AppSettings settings, int year, int month, List workDays, List vacationDays, List 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(); var daysInMonth = DateTime.DaysInMonth(year, month); var totalNet = TimeSpan.Zero; var totalTarget = TimeSpan.Zero; var totalOvertime = TimeSpan.Zero; container.Table(table => { table.ColumnsDefinition(columns => { 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); } } }); totalNetOut = totalNet; totalTargetOut = totalTarget; totalOvertimeOut = totalOvertime; } private void ComposeSummary(IContainer container, TimeSpan totalNet, TimeSpan totalTarget, TimeSpan totalOvertime) { container.Background(Colors.Grey.Lighten4).Padding(10).Row(row => { 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); }); }); } private void ComposeSignatures(IContainer container) { 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); }); }); } private void ComposeFooter(IContainer container) { container.AlignCenter().Text(x => { x.Span("Seite "); x.CurrentPageNumber(); x.Span(" von "); x.TotalPages(); }); } private static string FormatTs(TimeSpan ts, bool sign = false) { var neg = ts < TimeSpan.Zero; var abs = neg ? ts.Negate() : ts; var h = (int)abs.TotalHours; var m = abs.Minutes; if (!sign) return $"{h}:{m:D2} h"; return neg ? $"-{h}:{m:D2} h" : $"+{h}:{m:D2} h"; } } }