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 @@
+