Excel Export eingebaut

This commit is contained in:
MarcWieland
2026-06-25 20:22:35 +02:00
parent 2f95fa0e3e
commit b12e94128c
6 changed files with 470 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public static class ExcelExporter
{
public static async Task<byte[]> GenerateExcelExportAsync(
int userId,
DateOnly startDate,
DateOnly endDate,
string title,
ITimetrackerService trackerService,
TimetrackerDbContext db)
{
var settings = await trackerService.GetSettingsAsync(userId);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToDictionaryAsync(w => w.Date);
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync();
var state = settings.GermanState;
var holidayMap = holidaysList
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.ToDictionary(h => h.Date, h => h.Name);
var vacationMap = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
var user = await db.Users.FindAsync(userId);
var userName = user != null ? user.Username : "Mitarbeiter";
var rows = new List<ExportRow>();
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidayMap.ContainsKey(date);
bool isVacation = vacationMap.ContainsKey(date);
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
string status = "Wochenende";
if (isHoliday) status = "Feiertag";
else if (isVacation) status = "Urlaub";
else if (isWorkDay) status = "Arbeitstag";
TimeSpan? startTime = null;
TimeSpan? endTime = null;
TimeSpan breakTime = TimeSpan.Zero;
double actualHours = 0.0;
string note = "";
if (isHoliday) note = holidayMap[date];
else if (isVacation) note = vacationMap[date];
if (workDays.TryGetValue(date, out var wd))
{
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
if (startTime.HasValue && endTime.HasValue)
{
var gross = endTime.Value - startTime.Value;
if (gross > TimeSpan.Zero)
{
var breakTotal = 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 = breakTotal > minBreak ? breakTotal : minBreak;
breakTime = finalBreak;
actualHours = (gross - finalBreak).TotalHours;
if (actualHours < 0) actualHours = 0.0;
}
}
}
rows.Add(new ExportRow
{
Date = date,
Status = status,
StartTime = startTime,
EndTime = endTime,
BreakTime = breakTime,
ActualHours = actualHours,
TargetHours = targetHours,
DiffHours = actualHours - targetHours,
Note = note
});
}
return ExportToExcel(title, userName, startDate, endDate, rows);
}
public static byte[] ExportToExcel(
string title,
string userName,
DateOnly startDate,
DateOnly endDate,
List<ExportRow> rows)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
// Gitterlinien explizit einblenden
worksheet.ShowGridLines = true;
// Title Block
worksheet.Cell("A1").Value = title;
worksheet.Cell("A1").Style.Font.Bold = true;
worksheet.Cell("A1").Style.Font.FontSize = 16;
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
// Info Block
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
worksheet.Cell("A2").Style.Font.FontSize = 11;
worksheet.Cell("A2").Style.Font.Italic = true;
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
worksheet.Cell("A3").Style.Font.FontSize = 11;
worksheet.Cell("A3").Style.Font.Italic = true;
// Tabellenüberschriften (Zeile 5)
string[] headers = {
"Datum", "Wochentag", "Status", "Beginn", "Ende",
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
};
for (int i = 0; i < headers.Length; i++)
{
var cell = worksheet.Cell(5, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Font.FontColor = XLColor.White;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
}
// Datenzeilen
int currentRow = 6;
var deCulture = new CultureInfo("de-DE");
foreach (var r in rows)
{
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
worksheet.Cell(currentRow, 3).Value = r.Status;
if (r.StartTime.HasValue)
{
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 4).Value = "—";
}
if (r.EndTime.HasValue)
{
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 5).Value = "—";
}
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
if (r.DiffHours > 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
else if (r.DiffHours < 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
worksheet.Cell(currentRow, 10).Value = r.Note;
// Zebra-Streifen
if (currentRow % 2 == 0)
{
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
}
}
// Rahmenlinien
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
}
currentRow++;
}
// Summenzeile
currentRow++;
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
var actualSumRange = $"G6:G{currentRow - 2}";
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
var targetSumRange = $"H6:H{currentRow - 2}";
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
var diffSumRange = $"I6:I{currentRow - 2}";
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
// Summenrahmen
for (int col = 6; col <= 9; col++)
{
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
}
// Spaltenbreiten automatisch anpassen
worksheet.Columns().AdjustToContents();
using var memoryStream = new MemoryStream();
workbook.SaveAs(memoryStream);
return memoryStream.ToArray();
}
}
public class ExportRow
{
public DateOnly Date { get; set; }
public string Status { get; set; } = "";
public TimeSpan? StartTime { get; set; }
public TimeSpan? EndTime { get; set; }
public TimeSpan BreakTime { get; set; }
public double ActualHours { get; set; }
public double TargetHours { get; set; }
public double DiffHours { get; set; }
public string Note { get; set; } = "";
}
+77
View File
@@ -459,6 +459,83 @@ trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery]
return Results.Ok(days);
});
trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (DateOnly.TryParse(monday, out var date))
{
var mondayDate = date;
int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
mondayDate = mondayDate.AddDays(-diff);
var sundayDate = mondayDate.AddDays(6);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
mondayDate,
sundayDate,
$"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx");
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var deCulture = new System.Globalization.CultureInfo("de-DE");
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Monatsbericht {monthName} {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Monatsbericht_{monthName}_{year}.xlsx");
});
trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var startDate = new DateOnly(year, 1, 1);
var endDate = new DateOnly(year, 12, 31);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Jahresbericht {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Jahresbericht_{year}.xlsx");
});
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />