PDFs erzeugen
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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<WorkDay> workDays,
|
||||
List<VacationDay> vacationDays,
|
||||
List<PublicHoliday> 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<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> 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<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();
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user