Change report download from JSON to PDF

- Add GenerateSingleReport() method to PdfReportService for individual CV reports
- PDF includes: score header, employment verification table, timeline analysis,
  gaps/overlaps sections, and color-coded flags (critical/warning/info)
- Update Report.razor to use PdfReportService instead of JSON serialization
- Add TrueCV.Web.Services to _Imports.razor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 22:49:22 +01:00
parent 1a53431757
commit a6b24d2c64
3 changed files with 210 additions and 53 deletions

View File

@@ -9,6 +9,7 @@
@inject ILogger<Report> Logger
@inject IJSRuntime JSRuntime
@inject IAuditService AuditService
@inject PdfReportService PdfReportService
<PageTitle>Verification Report - TrueCV</PageTitle>
@@ -603,58 +604,11 @@
try
{
var reportData = new
{
CandidateName = _check.OriginalFileName,
GeneratedAt = _report.GeneratedAt,
OverallScore = _report.OverallScore,
ScoreLabel = _report.ScoreLabel,
EmploymentVerifications = _report.EmploymentVerifications.Select(v => new
{
v.ClaimedCompany,
ClaimedStartDate = v.ClaimedStartDate?.ToString("yyyy-MM"),
ClaimedEndDate = v.ClaimedEndDate?.ToString("yyyy-MM"),
v.MatchedCompanyName,
v.MatchedCompanyNumber,
v.MatchScore,
v.IsVerified,
v.VerificationNotes
}),
TimelineAnalysis = new
{
_report.TimelineAnalysis.TotalGapMonths,
_report.TimelineAnalysis.TotalOverlapMonths,
Gaps = _report.TimelineAnalysis.Gaps.Select(g => new
{
StartDate = g.StartDate.ToString("yyyy-MM"),
EndDate = g.EndDate.ToString("yyyy-MM"),
g.Months
}),
Overlaps = _report.TimelineAnalysis.Overlaps.Select(o => new
{
o.Company1,
o.Company2,
OverlapStart = o.OverlapStart.ToString("yyyy-MM"),
OverlapEnd = o.OverlapEnd.ToString("yyyy-MM"),
o.Months
})
},
Flags = _report.Flags.Select(f => new
{
f.Severity,
f.Title,
f.Description,
f.ScoreImpact
})
};
var candidateName = Path.GetFileNameWithoutExtension(_check.OriginalFileName);
var pdfBytes = PdfReportService.GenerateSingleReport(candidateName, _report);
var json = System.Text.Json.JsonSerializer.Serialize(reportData, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
var fileName = $"TrueCV_Report_{Path.GetFileNameWithoutExtension(_check.OriginalFileName)}_{DateTime.Now:yyyyMMdd}.json";
await DownloadFileAsync(fileName, json, "application/json");
var fileName = $"TrueCV_Report_{candidateName}_{DateTime.Now:yyyyMMdd}.pdf";
await DownloadFileAsync(fileName, pdfBytes, "application/pdf");
}
catch (Exception ex)
{
@@ -662,9 +616,8 @@
}
}
private async Task DownloadFileAsync(string fileName, string content, string contentType)
private async Task DownloadFileAsync(string fileName, byte[] bytes, string contentType)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, contentType);
}

View File

@@ -14,6 +14,7 @@
@using TrueCV.Web
@using TrueCV.Web.Components
@using TrueCV.Web.Components.Shared
@using TrueCV.Web.Services
@using TrueCV.Application.Interfaces
@using TrueCV.Application.DTOs
@using TrueCV.Application.Models

View File

@@ -1,11 +1,214 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using TrueCV.Application.Models;
namespace TrueCV.Web.Services;
public class PdfReportService
{
/// <summary>
/// Generates a detailed PDF report for a single CV verification.
/// </summary>
public byte[] GenerateSingleReport(string candidateName, VeracityReport report)
{
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(c => ComposeSingleReportHeader(c, candidateName, report));
page.Content().Element(c => ComposeSingleReportContent(c, report));
page.Footer().Element(ComposeFooter);
});
});
return document.GeneratePdf();
}
private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report)
{
var scoreColor = report.OverallScore > 70 ? Colors.Green.Darken1 :
(report.OverallScore >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1);
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").FontSize(14).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(100).AlignRight().Column(col =>
{
col.Item().AlignRight().Text(report.GeneratedAt.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
col.Item().AlignRight().Text(report.GeneratedAt.ToString("HH:mm")).FontSize(9).FontColor(Colors.Grey.Medium);
});
});
column.Item().PaddingTop(15).Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("Candidate: " + candidateName).Bold().FontSize(12);
});
row.ConstantItem(120).Border(2).BorderColor(scoreColor).Padding(10).AlignCenter().Column(col =>
{
col.Item().AlignCenter().Text(report.OverallScore.ToString()).Bold().FontSize(28).FontColor(scoreColor);
col.Item().AlignCenter().Text(report.ScoreLabel).FontSize(10).FontColor(scoreColor);
});
});
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
}
private void ComposeSingleReportContent(IContainer container, VeracityReport report)
{
container.PaddingVertical(15).Column(column =>
{
// Employment Verification Section
column.Item().Text("Employment Verification").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
column.Item().PaddingTop(10).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3); // Claimed
columns.RelativeColumn(2); // Period
columns.RelativeColumn(3); // Matched
columns.ConstantColumn(45); // Score
columns.ConstantColumn(55); // Status
});
table.Header(header =>
{
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Claimed Employer").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Period").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Matched Company").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Match").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Status").Bold().FontSize(9).FontColor(Colors.White);
});
bool alternate = false;
foreach (var emp in report.EmploymentVerifications)
{
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
var period = emp.ClaimedStartDate.HasValue
? $"{emp.ClaimedStartDate.Value:MMM yyyy} - {(emp.ClaimedEndDate.HasValue ? emp.ClaimedEndDate.Value.ToString("MMM yyyy") : "Present")}"
: "Not specified";
table.Cell().Background(bgColor).Padding(4).Text(emp.ClaimedCompany).FontSize(9);
table.Cell().Background(bgColor).Padding(4).Text(period).FontSize(8);
table.Cell().Background(bgColor).Padding(4).Text(emp.MatchedCompanyName ?? "No match").FontSize(9).FontColor(emp.IsVerified ? Colors.Black : Colors.Grey.Medium);
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.MatchScore + "%").FontSize(9);
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.IsVerified ? "Verified" : "Unverified").FontSize(8)
.FontColor(emp.IsVerified ? Colors.Green.Darken1 : Colors.Orange.Darken1);
alternate = !alternate;
}
});
// Timeline Section
column.Item().PaddingTop(20).Text("Timeline Analysis").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
column.Item().PaddingTop(10).Row(row =>
{
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
{
col.Item().Text("Employment Gaps").Bold().FontSize(10);
if (report.TimelineAnalysis.Gaps.Count > 0)
{
foreach (var gap in report.TimelineAnalysis.Gaps)
{
col.Item().PaddingTop(5).Text($"{gap.StartDate:MMM yyyy} - {gap.EndDate:MMM yyyy} ({gap.Months} months)").FontSize(9);
}
col.Item().PaddingTop(5).Text($"Total: {report.TimelineAnalysis.TotalGapMonths} months").Bold().FontSize(9).FontColor(Colors.Orange.Darken1);
}
else
{
col.Item().PaddingTop(5).Text("No significant gaps detected").FontSize(9).FontColor(Colors.Green.Darken1);
}
});
row.ConstantItem(15);
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
{
col.Item().Text("Concurrent Employment").Bold().FontSize(10);
if (report.TimelineAnalysis.Overlaps.Count > 0)
{
foreach (var overlap in report.TimelineAnalysis.Overlaps)
{
col.Item().PaddingTop(5).Text($"{overlap.Company1} & {overlap.Company2}").FontSize(9);
col.Item().Text($" {overlap.OverlapStart:MMM yyyy} - {overlap.OverlapEnd:MMM yyyy} ({overlap.Months} mo)").FontSize(8).FontColor(Colors.Grey.Darken1);
}
}
else
{
col.Item().PaddingTop(5).Text("No overlapping positions").FontSize(9).FontColor(Colors.Green.Darken1);
}
});
});
// Flags Section
if (report.Flags.Count > 0)
{
column.Item().PaddingTop(20).Text("Flags Raised").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
var criticalFlags = report.Flags.Where(f => f.Severity == "Critical").ToList();
var warningFlags = report.Flags.Where(f => f.Severity == "Warning").ToList();
var infoFlags = report.Flags.Where(f => f.Severity == "Info").ToList();
if (criticalFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Red.Lighten5).Border(1).BorderColor(Colors.Red.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Critical Issues").Bold().FontSize(10).FontColor(Colors.Red.Darken1);
foreach (var flag in criticalFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
if (warningFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Orange.Lighten5).Border(1).BorderColor(Colors.Orange.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Warnings").Bold().FontSize(10).FontColor(Colors.Orange.Darken1);
foreach (var flag in warningFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
if (infoFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Blue.Lighten5).Border(1).BorderColor(Colors.Blue.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Information").Bold().FontSize(10).FontColor(Colors.Blue.Darken1);
foreach (var flag in infoFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
}
});
}
/// <summary>
/// Generates a summary PDF report for multiple CV verifications (batch report).
/// </summary>
public byte[] GenerateReport(List<PdfReportData> data)
{
QuestPDF.Settings.License = LicenseType.Community;