feat: implement Excel export functionality for monthly timesheets with template support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
0d5b48b891
commit
e872fe200b
13 changed files with 584 additions and 0 deletions
|
|
@ -16,6 +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>
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -277,6 +278,11 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
|
||||||
viewMode = mode;
|
viewMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetExcelDownloadUrl()
|
||||||
|
{
|
||||||
|
return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}";
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetDayHeader(DateOnly date)
|
private static string GetDayHeader(DateOnly date)
|
||||||
{
|
{
|
||||||
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
|
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
|
||||||
|
|
|
||||||
19
Program.cs
19
Program.cs
|
|
@ -13,6 +13,7 @@ using WorkTracker.Components;
|
||||||
using WorkTracker.Configuration;
|
using WorkTracker.Configuration;
|
||||||
using WorkTracker.Services.Auth;
|
using WorkTracker.Services.Auth;
|
||||||
using WorkTracker.Services.Festivities;
|
using WorkTracker.Services.Festivities;
|
||||||
|
using WorkTracker.Services.Exports;
|
||||||
using WorkTracker.Services.Settings;
|
using WorkTracker.Services.Settings;
|
||||||
using WorkTracker.Services.Storage;
|
using WorkTracker.Services.Storage;
|
||||||
using WorkTracker.Services.WorkDays;
|
using WorkTracker.Services.WorkDays;
|
||||||
|
|
@ -74,6 +75,8 @@ builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>
|
||||||
builder.Services.AddScoped<AppThemeState>();
|
builder.Services.AddScoped<AppThemeState>();
|
||||||
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
||||||
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
||||||
|
builder.Services.AddSingleton<IMonthlyTimesheetExcelExporter, MonthlyTimesheetExcelExporter>();
|
||||||
|
builder.Services.AddScoped<IMonthlyTimesheetExcelExportService, MonthlyTimesheetExcelExportService>();
|
||||||
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
|
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
|
||||||
builder.Services.AddHostedService<SingleUserSeedService>();
|
builder.Services.AddHostedService<SingleUserSeedService>();
|
||||||
|
|
||||||
|
|
@ -245,6 +248,22 @@ app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOp
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async (
|
||||||
|
int year,
|
||||||
|
int month,
|
||||||
|
bool includePreview,
|
||||||
|
IMonthlyTimesheetExcelExportService exportService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (month is < 1 or > 12)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Month must be between 1 and 12.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = await exportService.ExportAsync(year, month, includePreview, cancellationToken);
|
||||||
|
return Results.File(file.Content, file.ContentType, file.FileName);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
15
Services/Exports/IMonthlyTimesheetExcelExportService.cs
Normal file
15
Services/Exports/IMonthlyTimesheetExcelExportService.cs
Normal 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";
|
||||||
|
}
|
||||||
8
Services/Exports/IMonthlyTimesheetExcelExporter.cs
Normal file
8
Services/Exports/IMonthlyTimesheetExcelExporter.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using WorkTracker.Domain;
|
||||||
|
|
||||||
|
namespace WorkTracker.Services.Exports;
|
||||||
|
|
||||||
|
public interface IMonthlyTimesheetExcelExporter
|
||||||
|
{
|
||||||
|
byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream);
|
||||||
|
}
|
||||||
33
Services/Exports/MonthlyTimesheetExcelExportService.cs
Normal file
33
Services/Exports/MonthlyTimesheetExcelExportService.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
230
Services/Exports/MonthlyTimesheetExcelExporter.cs
Normal file
230
Services/Exports/MonthlyTimesheetExcelExporter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Templates/monthly-timesheet-template.xlsx
Normal file
BIN
Templates/monthly-timesheet-template.xlsx
Normal file
Binary file not shown.
|
|
@ -8,15 +8,26 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
|
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
|
||||||
<PackageReference Include="NLog" Version="5.3.4" />
|
<PackageReference Include="NLog" Version="5.3.4" />
|
||||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.10" />
|
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="tests\**\*.cs" />
|
||||||
|
<EmbeddedResource Remove="tests\**\*" />
|
||||||
|
<None Remove="tests\**\*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="nlog.config">
|
<None Update="nlog.config">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update="Templates\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,55 @@
|
||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.5.2.0
|
VisualStudioVersion = 17.5.2.0
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker", "WorkTracker.csproj", "{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker", "WorkTracker.csproj", "{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker.Tests", "tests\WorkTracker.Tests\WorkTracker.Tests.csproj", "{87B6F668-25F6-4F14-B185-176514507ADE}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{87B6F668-25F6-4F14-B185-176514507ADE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {9E2849A1-F16A-4322-A66A-A64428230E83}
|
SolutionGuid = {9E2849A1-F16A-4322-A66A-A64428230E83}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|
|
||||||
Binary file not shown.
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal file
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using WorkTracker.Domain;
|
||||||
|
using WorkTracker.Services.Exports;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace WorkTracker.Tests;
|
||||||
|
|
||||||
|
public sealed class MonthlyTimesheetExcelExporterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Export_ForApril2026EmptyTimesheet_MatchesExpectedWorkbook()
|
||||||
|
{
|
||||||
|
var exporter = new MonthlyTimesheetExcelExporter();
|
||||||
|
var timesheet = CreateTimesheet(new DateOnly(2026, 4, 1), new HashSet<DateOnly>
|
||||||
|
{
|
||||||
|
new(2026, 4, 6),
|
||||||
|
new(2026, 4, 25)
|
||||||
|
});
|
||||||
|
var templatePath = GetTemplatePath();
|
||||||
|
|
||||||
|
using var templateStream = File.OpenRead(templatePath);
|
||||||
|
var workbookBytes = exporter.Export(timesheet, templateStream);
|
||||||
|
|
||||||
|
WorkbookAssert.Equivalent(GetExpectedWorkbookPath(), workbookBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Export_ForThirtyOneDayMonth_ShiftsTotalColumnAfterLastDay()
|
||||||
|
{
|
||||||
|
var exporter = new MonthlyTimesheetExcelExporter();
|
||||||
|
var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet<DateOnly>());
|
||||||
|
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);
|
||||||
|
|
||||||
|
Assert.Equal(31d, worksheet.Cell("AG1").GetDouble());
|
||||||
|
Assert.Equal("SUM(C2:AG2)", worksheet.Cell("AH2").FormulaA1);
|
||||||
|
Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet<DateOnly> holidays)
|
||||||
|
{
|
||||||
|
var lastDay = monthStart.AddMonths(1).AddDays(-1);
|
||||||
|
var days = new List<MonthlyTimesheetDayModel>();
|
||||||
|
for (var date = monthStart; date <= lastDay; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
days.Add(new MonthlyTimesheetDayModel
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||||
|
IsHoliday = holidays.Contains(date)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MonthlyTimesheetModel
|
||||||
|
{
|
||||||
|
Year = monthStart.Year,
|
||||||
|
Month = monthStart.Month,
|
||||||
|
Days = days,
|
||||||
|
Rows =
|
||||||
|
[
|
||||||
|
CreateRow("office", days.Count),
|
||||||
|
CreateRow("home", days.Count),
|
||||||
|
CreateRow("overtime", days.Count),
|
||||||
|
CreateRow("weekend", days.Count),
|
||||||
|
CreateRow("night", days.Count),
|
||||||
|
CreateRow("vacation", days.Count),
|
||||||
|
CreateRow("permit", days.Count),
|
||||||
|
CreateRow("compensatory-rest", days.Count),
|
||||||
|
CreateRow("sick", days.Count),
|
||||||
|
CreateRow("holiday", days.Count)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MonthlyTimesheetRowModel CreateRow(string key, int dayCount)
|
||||||
|
{
|
||||||
|
return new MonthlyTimesheetRowModel
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
DailyValues = Enumerable.Repeat<decimal?>(null, dayCount).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetExpectedWorkbookPath()
|
||||||
|
{
|
||||||
|
var repositoryRoot = FindRepositoryRoot();
|
||||||
|
var candidate = Path.Combine(repositoryRoot, "tests", "WorkTracker.Tests", "Expected", "monthly-timesheet-2026-04-empty.expected.xlsx");
|
||||||
|
return File.Exists(candidate)
|
||||||
|
? candidate
|
||||||
|
: GetTemplatePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTemplatePath()
|
||||||
|
{
|
||||||
|
var repositoryRoot = FindRepositoryRoot();
|
||||||
|
return Path.Combine(repositoryRoot, "Templates", "monthly-timesheet-template.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "WorkTracker.sln")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Unable to locate the WorkTracker repository root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal file
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\WorkTracker.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal file
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace WorkTracker.Tests;
|
||||||
|
|
||||||
|
internal static class WorkbookAssert
|
||||||
|
{
|
||||||
|
public static void Equivalent(string expectedPath, byte[] actualContent)
|
||||||
|
{
|
||||||
|
using var expectedWorkbook = new XLWorkbook(expectedPath);
|
||||||
|
using var actualStream = new MemoryStream(actualContent);
|
||||||
|
using var actualWorkbook = new XLWorkbook(actualStream);
|
||||||
|
|
||||||
|
var expectedWorksheet = expectedWorkbook.Worksheet(1);
|
||||||
|
var actualWorksheet = actualWorkbook.Worksheet(1);
|
||||||
|
|
||||||
|
Assert.Equal(expectedWorksheet.Name, actualWorksheet.Name);
|
||||||
|
Assert.Equal(expectedWorksheet.RangeUsed()?.RangeAddress.ToString(), actualWorksheet.RangeUsed()?.RangeAddress.ToString());
|
||||||
|
Assert.Equal(expectedWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()), actualWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()));
|
||||||
|
|
||||||
|
var usedRange = expectedWorksheet.RangeUsed() ?? throw new InvalidOperationException("Expected workbook must have a used range.");
|
||||||
|
foreach (var column in Enumerable.Range(usedRange.RangeAddress.FirstAddress.ColumnNumber, usedRange.ColumnCount()))
|
||||||
|
{
|
||||||
|
Assert.Equal(Math.Round(expectedWorksheet.Column(column).Width, 5), Math.Round(actualWorksheet.Column(column).Width, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cell in usedRange.Cells())
|
||||||
|
{
|
||||||
|
var actualCell = actualWorksheet.Cell(cell.Address.RowNumber, cell.Address.ColumnNumber);
|
||||||
|
Assert.Equal(GetCellValue(cell), GetCellValue(actualCell));
|
||||||
|
Assert.Equal(cell.FormulaA1, actualCell.FormulaA1);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(cell.FormulaA1))
|
||||||
|
{
|
||||||
|
Assert.Equal(cell.DataType, actualCell.DataType);
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedStyle = DescribeStyle(cell.Style);
|
||||||
|
var actualStyle = DescribeStyle(actualCell.Style);
|
||||||
|
Assert.True(expectedStyle == actualStyle, $"Style mismatch at {cell.Address}: expected '{expectedStyle}' actual '{actualStyle}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCellValue(IXLCell cell)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(cell.FormulaA1))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell.Value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeStyle(IXLStyle style)
|
||||||
|
{
|
||||||
|
return string.Join(
|
||||||
|
"|",
|
||||||
|
style.NumberFormat.NumberFormatId,
|
||||||
|
style.NumberFormat.Format,
|
||||||
|
style.Fill.PatternType,
|
||||||
|
DescribeColor(style.Fill.BackgroundColor),
|
||||||
|
DescribeColor(style.Fill.PatternColor),
|
||||||
|
style.Font.FontName,
|
||||||
|
style.Font.FontSize,
|
||||||
|
style.Font.Bold,
|
||||||
|
style.Alignment.Horizontal,
|
||||||
|
style.Alignment.Vertical,
|
||||||
|
style.Alignment.WrapText,
|
||||||
|
style.Border.LeftBorder,
|
||||||
|
DescribeColor(style.Border.LeftBorderColor),
|
||||||
|
style.Border.RightBorder,
|
||||||
|
DescribeColor(style.Border.RightBorderColor),
|
||||||
|
style.Border.TopBorder,
|
||||||
|
DescribeColor(style.Border.TopBorderColor),
|
||||||
|
style.Border.BottomBorder,
|
||||||
|
DescribeColor(style.Border.BottomBorderColor),
|
||||||
|
style.Protection.Locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
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