diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 8ebbc97..82445fe 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -16,7 +16,7 @@

@currentMonth.ToString("MMMM yyyy")

- Download Excel + Yearly Summary @@ -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()}"; diff --git a/Services/Exports/MonthlyTimesheetExcelExporter.cs b/Services/Exports/MonthlyTimesheetExcelExporter.cs index b6b7036..142c9b0 100644 --- a/Services/Exports/MonthlyTimesheetExcelExporter.cs +++ b/Services/Exports/MonthlyTimesheetExcelExporter.cs @@ -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 TimesheetRowMap = new Dictionary(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 days) + private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList 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 CaptureStylesForColumn(IXLWorksheet worksheet, int column) + { + var rowNumbers = new[] { 1, 2, 4, 12 }; + var styles = new Dictionary(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 firstColumnStyles, + IReadOnlyDictionary middleColumnStyles, + IReadOnlyDictionary lastColumnStyles, + IReadOnlyDictionary saturdayStyles, + IReadOnlyDictionary 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; } } \ No newline at end of file diff --git a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs index bc55c78..ba8bedc 100644 --- a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs +++ b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs @@ -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 + { + 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 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() + }; + } }