From b12e94128c90a9f945c2d49aa672dd30cb004b66 Mon Sep 17 00:00:00 2001 From: MarcWieland Date: Thu, 25 Jun 2026 20:22:35 +0200 Subject: [PATCH] Excel Export eingebaut --- .../Components/Pages/Home.razor | 9 + .../Components/Pages/Month.razor | 10 + .../Components/Pages/Stats.razor | 95 ++++++ timetracker.Server/Data/ExcelExporter.cs | 278 ++++++++++++++++++ timetracker.Server/Program.cs | 77 +++++ timetracker.Server/timetracker.Server.csproj | 1 + 6 files changed, 470 insertions(+) create mode 100644 timetracker.Server/Data/ExcelExporter.cs diff --git a/timetracker.Client/Components/Pages/Home.razor b/timetracker.Client/Components/Pages/Home.razor index 5691e06..eb7cb23 100644 --- a/timetracker.Client/Components/Pages/Home.razor +++ b/timetracker.Client/Components/Pages/Home.razor @@ -6,6 +6,7 @@ @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager KW @_kw – Wochenübersicht – Timetracker @@ -34,6 +35,10 @@ else + + + @if (!IsCurrentWeek) { @_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker @@ -32,6 +33,10 @@ else + + + @if (!IsCurrentMonth) { + + @* ── Excel Export ── *@ + + + + + Excel-Datenexport + Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter. + + + + + @* Wochenexport *@ + + + Wochenexport + + + Woche exportieren + + + + + @* Monatsexport *@ + + + Monatsexport + + + @for (int m = 1; m <= 12; m++) + { + var monthVal = m; + @_deCulture.DateTimeFormat.GetMonthName(monthVal) + } + + + @for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++) + { + var yearVal = y; + @yearVal + } + + + + Monat exportieren + + + + + @* Jahresexport *@ + + + Jahresexport + + @for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++) + { + var yearVal = y; + @yearVal + } + + + Jahr exportieren + + + + + + + @* ── Diagnostics Panel ── *@ @@ -263,6 +333,12 @@ else private double _weekTargetHours; private double _weekOvertimeHours; private double _maxHoursValue = 10.0; + + // Excel Export variables + private DateTime? _exportWeekDate = DateTime.Today; + private int _exportMonth = DateTime.Today.Month; + private int _exportMonthYear = DateTime.Today.Year; + private int _exportYear = DateTime.Today.Year; // Debug variables private int _rawDbDaysCount = 0; @@ -373,6 +449,25 @@ else return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}"; } + private void ExportWeek() + { + if (_exportWeekDate.HasValue) + { + var date = DateOnly.FromDateTime(_exportWeekDate.Value); + NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true); + } + } + + private void ExportMonth() + { + NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true); + } + + private void ExportYear() + { + NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true); + } + private sealed class DayStat { public DateOnly Date { get; set; } diff --git a/timetracker.Server/Data/ExcelExporter.cs b/timetracker.Server/Data/ExcelExporter.cs new file mode 100644 index 0000000..71b4a8e --- /dev/null +++ b/timetracker.Server/Data/ExcelExporter.cs @@ -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 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(); + + 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 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; } = ""; +} diff --git a/timetracker.Server/Program.cs b/timetracker.Server/Program.cs index 0640bf7..c9c05fa 100644 --- a/timetracker.Server/Program.cs +++ b/timetracker.Server/Program.cs @@ -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(); diff --git a/timetracker.Server/timetracker.Server.csproj b/timetracker.Server/timetracker.Server.csproj index 930c641..55c132d 100644 --- a/timetracker.Server/timetracker.Server.csproj +++ b/timetracker.Server/timetracker.Server.csproj @@ -8,6 +8,7 @@ +