Replace CSV export with PDF report generation

- Add QuestPDF library for professional PDF generation
- Create PdfReportService with formatted table layout
- Export includes score (color-coded), verified employers, gaps, and flags
- Report has header, footer with page numbers, and alternating row colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 19:28:22 +01:00
parent 7d6d5f12ea
commit acf4d96fae
4 changed files with 164 additions and 26 deletions

View File

@@ -7,6 +7,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Dashboard> Logger @inject ILogger<Dashboard> Logger
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject TrueCV.Web.Services.PdfReportService PdfReportService
<PageTitle>Dashboard - TrueCV</PageTitle> <PageTitle>Dashboard - TrueCV</PageTitle>
@@ -17,19 +18,19 @@
<p class="text-muted mb-0">View and manage your CV verification checks</p> <p class="text-muted mb-0">View and manage your CV verification checks</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="ExportToCsv" disabled="@(_isExporting || !HasCompletedChecks())"> <button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
@if (_isExporting) @if (_isExporting)
{ {
<span class="spinner-border spinner-border-sm me-1" role="status"></span> <span class="spinner-border spinner-border-sm me-1" role="status"></span>
} }
else else
{ {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-pdf me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> <path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H4zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> <path d="M4.603 12.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.701 19.701 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.187-.012.395-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.065.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.716 5.716 0 0 1-.911-.95 11.642 11.642 0 0 0-1.997.406 11.311 11.311 0 0 1-1.021 1.51c-.29.35-.608.655-.926.787a.793.793 0 0 1-.58.029zm1.379-1.901c-.166.076-.32.156-.459.238-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361.01.022.02.036.026.044a.27.27 0 0 0 .035-.012c.137-.056.355-.235.635-.572a8.18 8.18 0 0 0 .45-.606zm1.64-1.33a12.647 12.647 0 0 1 1.01-.193 11.666 11.666 0 0 1-.51-.858 20.741 20.741 0 0 1-.5 1.05zm2.446.45c.15.162.296.3.435.41.24.19.407.253.498.256a.107.107 0 0 0 .07-.015.307.307 0 0 0 .094-.125.436.436 0 0 0 .059-.2.095.095 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a3.881 3.881 0 0 0-.612-.053zM8.078 5.8a6.7 6.7 0 0 0 .2-.828c.031-.188.043-.343.038-.465a.613.613 0 0 0-.032-.198.517.517 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822.024.111.054.227.09.346z"/>
</svg> </svg>
} }
Export CSV Export PDF
</button> </button>
<a href="/check" class="btn btn-primary"> <a href="/check" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
@@ -302,7 +303,7 @@
}; };
} }
private async Task ExportToCsv() private async Task ExportToPdf()
{ {
if (_isExporting) return; if (_isExporting) return;
@@ -311,9 +312,7 @@
try try
{ {
var csvBuilder = new System.Text.StringBuilder(); var reportDataList = new List<TrueCV.Web.Services.PdfReportData>();
csvBuilder.AppendLine("Candidate,Upload Date,Score,Score Label,Employers Verified,Employers Unverified,Gap Months,Overlap Months,Critical Flags,Warning Flags");
foreach (var check in _checks) foreach (var check in _checks)
{ {
if (check.Status != "Completed") continue; if (check.Status != "Completed") continue;
@@ -337,18 +336,29 @@
else if (f.Severity == "Warning") warningFlags++; else if (f.Severity == "Warning") warningFlags++;
} }
var candidateName = EscapeCsv(Path.GetFileNameWithoutExtension(check.OriginalFileName)); reportDataList.Add(new TrueCV.Web.Services.PdfReportData
var uploadDate = check.CreatedAt.ToString("yyyy-MM-dd HH:mm"); {
CandidateName = Path.GetFileNameWithoutExtension(check.OriginalFileName) ?? "Unknown",
csvBuilder.AppendLine(candidateName + "," + uploadDate + "," + report.OverallScore + "," + EscapeCsv(report.ScoreLabel) + "," + verifiedCount + "," + unverifiedCount + "," + report.TimelineAnalysis.TotalGapMonths + "," + report.TimelineAnalysis.TotalOverlapMonths + "," + criticalFlags + "," + warningFlags); UploadDate = check.CreatedAt,
Score = report.OverallScore,
ScoreLabel = report.ScoreLabel,
VerifiedEmployers = verifiedCount,
UnverifiedEmployers = unverifiedCount,
GapMonths = report.TimelineAnalysis.TotalGapMonths,
OverlapMonths = report.TimelineAnalysis.TotalOverlapMonths,
CriticalFlags = criticalFlags,
WarningFlags = warningFlags
});
} }
var fileName = "TrueCV_Report_Summary_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv"; var pdfBytes = PdfReportService.GenerateReport(reportDataList);
await JSRuntime.InvokeVoidAsync("downloadCsvFile", fileName, csvBuilder.ToString()); var base64 = Convert.ToBase64String(pdfBytes);
var fileName = "TrueCV_Report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".pdf";
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, "application/pdf");
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "Error exporting CSV"); Logger.LogError(ex, "Error exporting PDF");
} }
finally finally
{ {
@@ -357,16 +367,6 @@
} }
} }
private static string EscapeCsv(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
if (field.Contains(",") || field.Contains("\"") || field.Contains("\n"))
{
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
return field;
}
private bool HasCompletedChecks() private bool HasCompletedChecks()
{ {
foreach (var c in _checks) foreach (var c in _checks)

View File

@@ -7,6 +7,7 @@ using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.Identity; using TrueCV.Infrastructure.Identity;
using TrueCV.Web; using TrueCV.Web;
using TrueCV.Web.Components; using TrueCV.Web.Components;
using TrueCV.Web.Services;
// Configure Serilog // Configure Serilog
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
@@ -32,6 +33,9 @@ try
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services) // Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
// Add Web services
builder.Services.AddScoped<PdfReportService>();
// Add Identity with secure password requirements // Add Identity with secure password requirements
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options => builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
{ {

View File

@@ -0,0 +1,133 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace TrueCV.Web.Services;
public class PdfReportService
{
public byte[] GenerateReport(List<PdfReportData> data)
{
QuestPDF.Settings.License = LicenseType.Community;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(10));
page.Header().Element(ComposeHeader);
page.Content().Element(c => ComposeContent(c, data));
page.Footer().Element(ComposeFooter);
});
});
return document.GeneratePdf();
}
private void ComposeHeader(IContainer container)
{
container.Column(column =>
{
column.Item().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("TrueCV").Bold().FontSize(24).FontColor(Colors.Blue.Darken2);
col.Item().Text("CV Verification Report Summary").FontSize(14).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(100).AlignRight().Text(DateTime.Now.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
});
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
}
private void ComposeContent(IContainer container, List<PdfReportData> data)
{
container.PaddingVertical(20).Column(column =>
{
column.Item().Text("Summary of " + data.Count + " Verified CVs").Bold().FontSize(12);
column.Item().PaddingTop(15);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(2);
columns.ConstantColumn(50);
columns.RelativeColumn(2);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
});
table.Header(header =>
{
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Candidate").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Date").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Score").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Rating").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Verified").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Gaps").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Critical").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Warnings").Bold().FontColor(Colors.White);
});
bool alternate = false;
foreach (var item in data)
{
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
var scoreColor = item.Score > 70 ? Colors.Green.Darken1 : (item.Score >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1);
table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName);
table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy"));
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Score.ToString()).Bold().FontColor(scoreColor);
table.Cell().Background(bgColor).Padding(5).Text(item.ScoreLabel);
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.VerifiedEmployers + "/" + (item.VerifiedEmployers + item.UnverifiedEmployers));
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.GapMonths + "mo");
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.CriticalFlags.ToString()).FontColor(item.CriticalFlags > 0 ? Colors.Red.Darken1 : Colors.Grey.Medium);
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.WarningFlags.ToString()).FontColor(item.WarningFlags > 0 ? Colors.Orange.Darken1 : Colors.Grey.Medium);
alternate = !alternate;
}
});
});
}
private void ComposeFooter(IContainer container)
{
container.Column(column =>
{
column.Item().LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
column.Item().PaddingTop(10).Row(row =>
{
row.RelativeItem().Text("Generated by TrueCV - CV Verification Platform").FontSize(8).FontColor(Colors.Grey.Medium);
row.RelativeItem().AlignRight().Text(x =>
{
x.Span("Page ").FontSize(8).FontColor(Colors.Grey.Medium);
x.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Medium);
x.Span(" of ").FontSize(8).FontColor(Colors.Grey.Medium);
x.TotalPages().FontSize(8).FontColor(Colors.Grey.Medium);
});
});
});
}
}
public class PdfReportData
{
public string CandidateName { get; set; } = "";
public DateTime UploadDate { get; set; }
public int Score { get; set; }
public string ScoreLabel { get; set; } = "";
public int VerifiedEmployers { get; set; }
public int UnverifiedEmployers { get; set; }
public int GapMonths { get; set; }
public int OverlapMonths { get; set; }
public int CriticalFlags { get; set; }
public int WarningFlags { get; set; }
}

View File

@@ -11,6 +11,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.*" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.*" />
<PackageReference Include="QuestPDF" Version="2025.12.3" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup> </ItemGroup>