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:
@@ -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)
|
||||||
|
|||||||
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
133
src/TrueCV.Web/Services/PdfReportService.cs
Normal file
133
src/TrueCV.Web/Services/PdfReportService.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user