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> <button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2> <h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button> <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> <a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
</div> </div>
@ -278,6 +278,20 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
viewMode = mode; viewMode = mode;
} }
private async Task DownloadExcelAsync()
{
try
{
await JS.InvokeVoidAsync("workTrackerDownloads.downloadFromUrl", GetExcelDownloadUrl());
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
}
private string GetExcelDownloadUrl() private string GetExcelDownloadUrl()
{ {
return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}"; 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 DayHeaderRow = 1;
private const int DayStartColumn = 3; private const int DayStartColumn = 3;
private const int TemplateDayCount = 30; 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) 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(); workbook.DefinedNames.DeleteAll();
var worksheet = workbook.Worksheet(1); var worksheet = workbook.Worksheet(1);
var dayCount = timesheet.Days.Count; var dayCount = timesheet.Days.Count;
var styleCatalog = CaptureDayStyleCatalog(worksheet);
AdjustDayColumns(worksheet, dayCount); AdjustDayColumns(worksheet, dayCount);
ApplyDayHeaders(worksheet, timesheet.Days); ApplyDayHeaders(worksheet, timesheet.Days, styleCatalog);
ApplyRowValues(worksheet, timesheet.Rows, dayCount); ApplyRowValues(worksheet, timesheet.Rows, dayCount);
ApplyTotalFormulas(worksheet, 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; var lastDayColumn = DayStartColumn + days.Count - 1;
@ -74,11 +77,11 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
var headerCell = worksheet.Cell(DayHeaderRow, column); var headerCell = worksheet.Cell(DayHeaderRow, column);
headerCell.Value = day.Date.Day; 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) 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)); return new DayStyleCatalog(
var baseStyle = baseCell.Style; 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)) if (!ShouldHighlight(day))
{ {
@ -132,9 +157,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return; return;
} }
var sampleColumn = GetHighlightSampleColumn(day, rowNumber); var sampleStyle = styleCatalog.GetHighlightStyle(rowNumber, day);
var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn);
var sampleStyle = sampleCell.Style;
targetCell.Style = sampleStyle; targetCell.Style = sampleStyle;
targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder; targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder;
@ -152,7 +175,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return day.IsWeekend || day.IsHoliday; return day.IsWeekend || day.IsHoliday;
} }
private static int GetBaseStyleRowAddress(int rowNumber) private static int GetStyleRow(int rowNumber)
{ {
return rowNumber switch 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, var styleRow = GetStyleRow(rowNumber);
2 => 2,
4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4, if (isFirstColumn)
12 => 12, {
_ => rowNumber return firstColumnStyles[styleRow];
};
} }
private static int GetBaseStyleColumn(int column, int lastDayColumn) if (isLastColumn)
{ {
if (column == DayStartColumn) return lastColumnStyles[styleRow];
{
return DayStartColumn;
} }
if (column == lastDayColumn) return middleColumnStyles[styleRow];
{
return lastDayColumn == DayStartColumn + TemplateDayCount - 1
? DayStartColumn + TemplateDayCount - 1
: DayStartColumn + 1;
} }
return DayStartColumn + 1; public IXLStyle GetHighlightStyle(int rowNumber, MonthlyTimesheetDayModel day)
{
var styleRow = GetStyleRow(rowNumber);
if (rowNumber is 1 or 2)
{
return day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday
? sundayHolidayStyles[styleRow]
: saturdayStyles[styleRow];
} }
private static int GetHighlightSampleColumn(MonthlyTimesheetDayModel day, int rowNumber) return saturdayStyles[styleRow];
{ }
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;
} }
} }

View file

@ -42,6 +42,43 @@ public sealed class MonthlyTimesheetExcelExporterTests
Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString()); 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) private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet<DateOnly> holidays)
{ {
var lastDay = monthStart.AddMonths(1).AddDays(-1); 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."); 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()
};
}
} }