feat: enhance Excel export functionality with improved styling and download handling
All checks were successful
Publish Container / publish (push) Successful in 5m39s
All checks were successful
Publish Container / publish (push) Successful in 5m39s
This commit is contained in:
parent
bf333c4a00
commit
8438a63bd8
3 changed files with 128 additions and 57 deletions
|
|
@ -16,7 +16,7 @@
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« 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 »</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</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()}";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
12 => 12,
|
|
||||||
_ => rowNumber
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetBaseStyleColumn(int column, int lastDayColumn)
|
if (isFirstColumn)
|
||||||
{
|
|
||||||
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;
|
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 saturdayStyles[styleRow];
|
||||||
{
|
|
||||||
return 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 6;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue