feat: enhance Excel export functionality with improved styling and download handling
All checks were successful
Publish Container / publish (push) Successful in 5m39s

This commit is contained in:
Marco 2026-05-20 11:34:54 +02:00
commit 8438a63bd8
3 changed files with 128 additions and 57 deletions

View file

@ -16,7 +16,7 @@
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
<a class="btn btn-success btn-sm ms-auto" href="@GetExcelDownloadUrl()">Download Excel</a>
<button type="button" class="btn btn-success btn-sm ms-auto" @onclick="DownloadExcelAsync">Download Excel</button>
<a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
</div>
@ -278,6 +278,20 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
viewMode = mode;
}
private async Task DownloadExcelAsync()
{
try
{
await JS.InvokeVoidAsync("workTrackerDownloads.downloadFromUrl", GetExcelDownloadUrl());
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
}
private string GetExcelDownloadUrl()
{
return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}";

View file

@ -8,6 +8,8 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
private const int DayHeaderRow = 1;
private const int DayStartColumn = 3;
private const int TemplateDayCount = 30;
private const int SaturdaySampleColumn = 6;
private const int SundayHolidaySampleColumn = 7;
private static readonly IReadOnlyDictionary<string, int> TimesheetRowMap = new Dictionary<string, int>(StringComparer.Ordinal)
{
@ -37,9 +39,10 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
workbook.DefinedNames.DeleteAll();
var worksheet = workbook.Worksheet(1);
var dayCount = timesheet.Days.Count;
var styleCatalog = CaptureDayStyleCatalog(worksheet);
AdjustDayColumns(worksheet, dayCount);
ApplyDayHeaders(worksheet, timesheet.Days);
ApplyDayHeaders(worksheet, timesheet.Days, styleCatalog);
ApplyRowValues(worksheet, timesheet.Rows, dayCount);
ApplyTotalFormulas(worksheet, dayCount);
@ -63,7 +66,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
}
}
private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList<MonthlyTimesheetDayModel> days)
private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList<MonthlyTimesheetDayModel> days, DayStyleCatalog styleCatalog)
{
var lastDayColumn = DayStartColumn + days.Count - 1;
@ -74,11 +77,11 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
var headerCell = worksheet.Cell(DayHeaderRow, column);
headerCell.Value = day.Date.Day;
ApplyDayStyle(worksheet, headerCell, day, column, lastDayColumn, 1);
ApplyDayStyle(headerCell, day, column, lastDayColumn, 1, styleCatalog);
foreach (var rowNumber in TimesheetRowMap.Values)
{
ApplyDayStyle(worksheet, worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber);
ApplyDayStyle(worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber, styleCatalog);
}
}
}
@ -121,10 +124,32 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
}
}
private static void ApplyDayStyle(IXLWorksheet worksheet, IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber)
private static DayStyleCatalog CaptureDayStyleCatalog(IXLWorksheet worksheet)
{
var baseCell = worksheet.Cell(GetBaseStyleRowAddress(rowNumber), GetBaseStyleColumn(column, lastDayColumn));
var baseStyle = baseCell.Style;
return new DayStyleCatalog(
CaptureStylesForColumn(worksheet, DayStartColumn),
CaptureStylesForColumn(worksheet, DayStartColumn + 1),
CaptureStylesForColumn(worksheet, DayStartColumn + TemplateDayCount - 1),
CaptureStylesForColumn(worksheet, SaturdaySampleColumn),
CaptureStylesForColumn(worksheet, SundayHolidaySampleColumn));
}
private static IReadOnlyDictionary<int, IXLStyle> CaptureStylesForColumn(IXLWorksheet worksheet, int column)
{
var rowNumbers = new[] { 1, 2, 4, 12 };
var styles = new Dictionary<int, IXLStyle>(rowNumbers.Length);
foreach (var rowNumber in rowNumbers)
{
styles[rowNumber] = worksheet.Cell(rowNumber, column).Style;
}
return styles;
}
private static void ApplyDayStyle(IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber, DayStyleCatalog styleCatalog)
{
var baseStyle = styleCatalog.GetBaseStyle(rowNumber, column == DayStartColumn, column == lastDayColumn);
if (!ShouldHighlight(day))
{
@ -132,9 +157,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return;
}
var sampleColumn = GetHighlightSampleColumn(day, rowNumber);
var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn);
var sampleStyle = sampleCell.Style;
var sampleStyle = styleCatalog.GetHighlightStyle(rowNumber, day);
targetCell.Style = sampleStyle;
targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder;
@ -152,7 +175,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return day.IsWeekend || day.IsHoliday;
}
private static int GetBaseStyleRowAddress(int rowNumber)
private static int GetStyleRow(int rowNumber)
{
return rowNumber switch
{
@ -164,67 +187,42 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
};
}
private static int GetSampleStyleRow(int rowNumber)
private sealed class DayStyleCatalog(
IReadOnlyDictionary<int, IXLStyle> firstColumnStyles,
IReadOnlyDictionary<int, IXLStyle> middleColumnStyles,
IReadOnlyDictionary<int, IXLStyle> lastColumnStyles,
IReadOnlyDictionary<int, IXLStyle> saturdayStyles,
IReadOnlyDictionary<int, IXLStyle> sundayHolidayStyles)
{
return rowNumber switch
public IXLStyle GetBaseStyle(int rowNumber, bool isFirstColumn, bool isLastColumn)
{
1 => 1,
2 => 2,
4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
12 => 12,
_ => rowNumber
};
}
var styleRow = GetStyleRow(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)
if (isFirstColumn)
{
return 7;
return firstColumnStyles[styleRow];
}
if (day.Date.DayOfWeek == DayOfWeek.Saturday)
if (isLastColumn)
{
return 6;
return lastColumnStyles[styleRow];
}
return 7;
return middleColumnStyles[styleRow];
}
if (rowNumber == 2)
public IXLStyle GetHighlightStyle(int rowNumber, MonthlyTimesheetDayModel day)
{
if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
var styleRow = GetStyleRow(rowNumber);
if (rowNumber is 1 or 2)
{
return 7;
return day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday
? sundayHolidayStyles[styleRow]
: saturdayStyles[styleRow];
}
if (day.Date.DayOfWeek == DayOfWeek.Saturday)
{
return 6;
}
return 7;
return saturdayStyles[styleRow];
}
return 6;
}
}

View file

@ -42,6 +42,43 @@ public sealed class MonthlyTimesheetExcelExporterTests
Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString());
}
[Fact]
public void Export_ForMonthWhereSecondDayIsWeekend_OnlyHighlightsWeekendAndHolidayDays()
{
var exporter = new MonthlyTimesheetExcelExporter();
var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet<DateOnly>
{
new(2026, 5, 4)
});
var templatePath = GetTemplatePath();
using var templateStream = File.OpenRead(templatePath);
var workbookBytes = exporter.Export(timesheet, templateStream);
using var workbook = new XLWorkbook(new MemoryStream(workbookBytes));
var worksheet = workbook.Worksheet(1);
var headerRegularFill = GetFillSignature(worksheet.Cell("C1"));
var headerSaturdayFill = GetFillSignature(worksheet.Cell("D1"));
var headerSundayFill = GetFillSignature(worksheet.Cell("E1"));
var headerHolidayFill = GetFillSignature(worksheet.Cell("F1"));
var headerNextWeekdayFill = GetFillSignature(worksheet.Cell("G1"));
Assert.NotEqual(headerRegularFill, headerSaturdayFill);
Assert.NotEqual(headerRegularFill, headerSundayFill);
Assert.Equal(headerSundayFill, headerHolidayFill);
Assert.Equal(headerRegularFill, headerNextWeekdayFill);
var bodyRegularFill = GetFillSignature(worksheet.Cell("C4"));
var bodySaturdayFill = GetFillSignature(worksheet.Cell("D4"));
var bodyHolidayFill = GetFillSignature(worksheet.Cell("F4"));
var bodyNextWeekdayFill = GetFillSignature(worksheet.Cell("G4"));
Assert.NotEqual(bodyRegularFill, bodySaturdayFill);
Assert.Equal(bodySaturdayFill, bodyHolidayFill);
Assert.Equal(bodyRegularFill, bodyNextWeekdayFill);
}
private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet<DateOnly> holidays)
{
var lastDay = monthStart.AddMonths(1).AddDays(-1);
@ -116,4 +153,26 @@ public sealed class MonthlyTimesheetExcelExporterTests
throw new DirectoryNotFoundException("Unable to locate the WorkTracker repository root.");
}
private static string GetFillSignature(IXLCell cell)
{
var fill = cell.Style.Fill;
return string.Join(
"|",
fill.PatternType,
DescribeColor(fill.BackgroundColor),
DescribeColor(fill.PatternColor));
}
private static string DescribeColor(XLColor color)
{
return color.ColorType switch
{
XLColorType.Color => $"rgb:{color.Color.ToArgb()}",
XLColorType.Indexed => $"indexed:{color.Indexed}",
XLColorType.Theme => $"theme:{color.ThemeColor}:{Math.Round(color.ThemeTint, 12)}",
_ => color.ColorType.ToString()
};
}
}