feat: implement Excel export functionality for monthly timesheets with template support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco 2026-04-24 10:45:44 +02:00
commit e872fe200b
13 changed files with 584 additions and 0 deletions

View file

@ -0,0 +1,15 @@
namespace WorkTracker.Services.Exports;
public interface IMonthlyTimesheetExcelExportService
{
Task<MonthlyTimesheetExcelFile> ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
}
public sealed class MonthlyTimesheetExcelFile
{
public required string FileName { get; init; }
public required byte[] Content { get; init; }
public string ContentType => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}

View file

@ -0,0 +1,8 @@
using WorkTracker.Domain;
namespace WorkTracker.Services.Exports;
public interface IMonthlyTimesheetExcelExporter
{
byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream);
}

View file

@ -0,0 +1,33 @@
using WorkTracker.Services.WorkDays;
namespace WorkTracker.Services.Exports;
public sealed class MonthlyTimesheetExcelExportService(
IWorkDayService workDayService,
IMonthlyTimesheetExcelExporter exporter,
IWebHostEnvironment environment) : IMonthlyTimesheetExcelExportService
{
private readonly IWorkDayService workDayService = workDayService;
private readonly IMonthlyTimesheetExcelExporter exporter = exporter;
private readonly IWebHostEnvironment environment = environment;
public async Task<MonthlyTimesheetExcelFile> ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
{
var templatePath = Path.Combine(environment.ContentRootPath, "Templates", "monthly-timesheet-template.xlsx");
if (!File.Exists(templatePath))
{
throw new FileNotFoundException("Monthly timesheet template not found.", templatePath);
}
var timesheet = await workDayService.GetMonthlyTimesheetAsync(year, month, includePreview, cancellationToken);
await using var templateStream = File.OpenRead(templatePath);
var content = exporter.Export(timesheet, templateStream);
return new MonthlyTimesheetExcelFile
{
FileName = $"timesheet-{year}-{month:00}.xlsx",
Content = content
};
}
}

View file

@ -0,0 +1,230 @@
using ClosedXML.Excel;
using WorkTracker.Domain;
namespace WorkTracker.Services.Exports;
public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExporter
{
private const int DayHeaderRow = 1;
private const int DayStartColumn = 3;
private const int TemplateDayCount = 30;
private static readonly IReadOnlyDictionary<string, int> TimesheetRowMap = new Dictionary<string, int>(StringComparer.Ordinal)
{
["office"] = 2,
["home"] = 4,
["overtime"] = 5,
["weekend"] = 6,
["night"] = 7,
["vacation"] = 8,
["permit"] = 9,
["compensatory-rest"] = 10,
["sick"] = 11,
["holiday"] = 12
};
public byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream)
{
ArgumentNullException.ThrowIfNull(timesheet);
ArgumentNullException.ThrowIfNull(templateStream);
if (templateStream.CanSeek)
{
templateStream.Position = 0;
}
using var workbook = new XLWorkbook(templateStream);
workbook.DefinedNames.DeleteAll();
var worksheet = workbook.Worksheet(1);
var dayCount = timesheet.Days.Count;
AdjustDayColumns(worksheet, dayCount);
ApplyDayHeaders(worksheet, timesheet.Days);
ApplyRowValues(worksheet, timesheet.Rows, dayCount);
ApplyTotalFormulas(worksheet, dayCount);
using var output = new MemoryStream();
workbook.SaveAs(output);
return output.ToArray();
}
private static void AdjustDayColumns(IXLWorksheet worksheet, int dayCount)
{
var totalColumn = DayStartColumn + TemplateDayCount;
var delta = dayCount - TemplateDayCount;
if (delta > 0)
{
worksheet.Column(totalColumn).InsertColumnsBefore(delta);
}
else if (delta < 0)
{
worksheet.Columns(DayStartColumn + dayCount, DayStartColumn + TemplateDayCount - 1).Delete();
}
}
private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList<MonthlyTimesheetDayModel> days)
{
var lastDayColumn = DayStartColumn + days.Count - 1;
for (var index = 0; index < days.Count; index++)
{
var day = days[index];
var column = DayStartColumn + index;
var headerCell = worksheet.Cell(DayHeaderRow, column);
headerCell.Value = day.Date.Day;
ApplyDayStyle(worksheet, headerCell, day, column, lastDayColumn, 1);
foreach (var rowNumber in TimesheetRowMap.Values)
{
ApplyDayStyle(worksheet, worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber);
}
}
}
private static void ApplyRowValues(IXLWorksheet worksheet, IReadOnlyList<MonthlyTimesheetRowModel> rows, int dayCount)
{
foreach (var row in rows)
{
if (!TimesheetRowMap.TryGetValue(row.Key, out var worksheetRow))
{
continue;
}
for (var dayIndex = 0; dayIndex < dayCount; dayIndex++)
{
var cell = worksheet.Cell(worksheetRow, DayStartColumn + dayIndex);
var value = dayIndex < row.DailyValues.Count ? row.DailyValues[dayIndex] : null;
if (value.HasValue && value.Value > 0m)
{
cell.Value = value.Value;
}
else
{
cell.Clear(XLClearOptions.Contents);
}
}
}
}
private static void ApplyTotalFormulas(IXLWorksheet worksheet, int dayCount)
{
var lastDayColumn = DayStartColumn + dayCount - 1;
var totalColumn = lastDayColumn + 1;
var firstDayColumnLetter = XLHelper.GetColumnLetterFromNumber(DayStartColumn);
var lastDayColumnLetter = XLHelper.GetColumnLetterFromNumber(lastDayColumn);
foreach (var rowNumber in TimesheetRowMap.Values)
{
worksheet.Cell(rowNumber, totalColumn).FormulaA1 = $"SUM({firstDayColumnLetter}{rowNumber}:{lastDayColumnLetter}{rowNumber})";
}
}
private static void ApplyDayStyle(IXLWorksheet worksheet, IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber)
{
var baseCell = worksheet.Cell(GetBaseStyleRowAddress(rowNumber), GetBaseStyleColumn(column, lastDayColumn));
var baseStyle = baseCell.Style;
if (!ShouldHighlight(day))
{
targetCell.Style = baseStyle;
return;
}
var sampleColumn = GetHighlightSampleColumn(day, rowNumber);
var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn);
var sampleStyle = sampleCell.Style;
targetCell.Style = sampleStyle;
targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder;
targetCell.Style.Border.LeftBorderColor = baseStyle.Border.LeftBorderColor;
targetCell.Style.Border.RightBorder = baseStyle.Border.RightBorder;
targetCell.Style.Border.RightBorderColor = baseStyle.Border.RightBorderColor;
targetCell.Style.Border.TopBorder = baseStyle.Border.TopBorder;
targetCell.Style.Border.TopBorderColor = baseStyle.Border.TopBorderColor;
targetCell.Style.Border.BottomBorder = baseStyle.Border.BottomBorder;
targetCell.Style.Border.BottomBorderColor = baseStyle.Border.BottomBorderColor;
}
private static bool ShouldHighlight(MonthlyTimesheetDayModel day)
{
return day.IsWeekend || day.IsHoliday;
}
private static int GetBaseStyleRowAddress(int rowNumber)
{
return rowNumber switch
{
1 => 1,
2 => 2,
4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
12 => 12,
_ => rowNumber
};
}
private static int GetSampleStyleRow(int rowNumber)
{
return rowNumber switch
{
1 => 1,
2 => 2,
4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
12 => 12,
_ => rowNumber
};
}
private static int GetBaseStyleColumn(int column, int lastDayColumn)
{
if (column == DayStartColumn)
{
return DayStartColumn;
}
if (column == lastDayColumn)
{
return lastDayColumn == DayStartColumn + TemplateDayCount - 1
? DayStartColumn + TemplateDayCount - 1
: DayStartColumn + 1;
}
return DayStartColumn + 1;
}
private static int GetHighlightSampleColumn(MonthlyTimesheetDayModel day, int rowNumber)
{
if (rowNumber == 1)
{
if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
{
return 7;
}
if (day.Date.DayOfWeek == DayOfWeek.Saturday)
{
return 6;
}
return 7;
}
if (rowNumber == 2)
{
if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
{
return 7;
}
if (day.Date.DayOfWeek == DayOfWeek.Saturday)
{
return 6;
}
return 7;
}
return 6;
}
}