using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel.Tables; using MigraDoc.Rendering; using System; using System.Collections.Generic; using System.Linq; using timetracker.Shared; namespace timetracker.Server.Services { public class PdfGeneratorService { public byte[] GenerateTimesheetPdf( User user, Tenant? tenant, AppSettings settings, int year, int month, List workDays, List vacationDays, List holidays) { if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null) { PdfSharp.Fonts.GlobalFontSettings.FontResolver = new PdfFontResolver(); } 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"; 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(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"; 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(Section section, Document document, User user, AppSettings settings, int year, int month, List workDays, List vacationDays, List holidays) { ComposeTable(section, document, settings, year, month, workDays, vacationDays, holidays, out var totalNet, out var totalTarget, out var totalOvertime); ComposeSummary(section, totalNet, totalTarget, totalOvertime); ComposeSignatures(section); } private void ComposeTable(Section section, Document document, 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; 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++) { 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 = 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(Section section, TimeSpan totalNet, TimeSpan totalTarget, TimeSpan totalOvertime) { 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) { saldoPara.Format.Font.Color = totalOvertime < TimeSpan.Zero ? Colors.DarkRed : Colors.DarkGreen; } } private void ComposeSignatures(Section section) { 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(Section section) { 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) { 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"; } } }