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 ILogger<Dashboard> Logger
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject TrueCV.Web.Services.PdfReportService PdfReportService
|
||||
|
||||
<PageTitle>Dashboard - TrueCV</PageTitle>
|
||||
|
||||
@@ -17,19 +18,19 @@
|
||||
<p class="text-muted mb-0">View and manage your CV verification checks</p>
|
||||
</div>
|
||||
<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)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
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">
|
||||
<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="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"/>
|
||||
<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="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="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>
|
||||
}
|
||||
Export CSV
|
||||
Export PDF
|
||||
</button>
|
||||
<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">
|
||||
@@ -302,7 +303,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ExportToCsv()
|
||||
private async Task ExportToPdf()
|
||||
{
|
||||
if (_isExporting) return;
|
||||
|
||||
@@ -311,9 +312,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var csvBuilder = new System.Text.StringBuilder();
|
||||
csvBuilder.AppendLine("Candidate,Upload Date,Score,Score Label,Employers Verified,Employers Unverified,Gap Months,Overlap Months,Critical Flags,Warning Flags");
|
||||
|
||||
var reportDataList = new List<TrueCV.Web.Services.PdfReportData>();
|
||||
foreach (var check in _checks)
|
||||
{
|
||||
if (check.Status != "Completed") continue;
|
||||
@@ -337,18 +336,29 @@
|
||||
else if (f.Severity == "Warning") warningFlags++;
|
||||
}
|
||||
|
||||
var candidateName = EscapeCsv(Path.GetFileNameWithoutExtension(check.OriginalFileName));
|
||||
var uploadDate = check.CreatedAt.ToString("yyyy-MM-dd HH:mm");
|
||||
|
||||
csvBuilder.AppendLine(candidateName + "," + uploadDate + "," + report.OverallScore + "," + EscapeCsv(report.ScoreLabel) + "," + verifiedCount + "," + unverifiedCount + "," + report.TimelineAnalysis.TotalGapMonths + "," + report.TimelineAnalysis.TotalOverlapMonths + "," + criticalFlags + "," + warningFlags);
|
||||
reportDataList.Add(new TrueCV.Web.Services.PdfReportData
|
||||
{
|
||||
CandidateName = Path.GetFileNameWithoutExtension(check.OriginalFileName) ?? "Unknown",
|
||||
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";
|
||||
await JSRuntime.InvokeVoidAsync("downloadCsvFile", fileName, csvBuilder.ToString());
|
||||
var pdfBytes = PdfReportService.GenerateReport(reportDataList);
|
||||
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)
|
||||
{
|
||||
Logger.LogError(ex, "Error exporting CSV");
|
||||
Logger.LogError(ex, "Error exporting PDF");
|
||||
}
|
||||
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()
|
||||
{
|
||||
foreach (var c in _checks)
|
||||
|
||||
@@ -7,6 +7,7 @@ using TrueCV.Infrastructure.Data;
|
||||
using TrueCV.Infrastructure.Identity;
|
||||
using TrueCV.Web;
|
||||
using TrueCV.Web.Components;
|
||||
using TrueCV.Web.Services;
|
||||
|
||||
// Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -32,6 +33,9 @@ try
|
||||
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Add Web services
|
||||
builder.Services.AddScoped<PdfReportService>();
|
||||
|
||||
// Add Identity with secure password requirements
|
||||
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>
|
||||
</PackageReference>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user