diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor index 46abbb7..8b6c349 100644 --- a/src/TrueCV.Web/Components/Pages/Report.razor +++ b/src/TrueCV.Web/Components/Pages/Report.razor @@ -9,6 +9,7 @@ @inject ILogger Logger @inject IJSRuntime JSRuntime @inject IAuditService AuditService +@inject PdfReportService PdfReportService Verification Report - TrueCV @@ -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); } diff --git a/src/TrueCV.Web/Components/_Imports.razor b/src/TrueCV.Web/Components/_Imports.razor index bed2d80..e92bc03 100644 --- a/src/TrueCV.Web/Components/_Imports.razor +++ b/src/TrueCV.Web/Components/_Imports.razor @@ -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 diff --git a/src/TrueCV.Web/Services/PdfReportService.cs b/src/TrueCV.Web/Services/PdfReportService.cs index 03e6461..44d0471 100644 --- a/src/TrueCV.Web/Services/PdfReportService.cs +++ b/src/TrueCV.Web/Services/PdfReportService.cs @@ -1,11 +1,214 @@ using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; +using TrueCV.Application.Models; namespace TrueCV.Web.Services; public class PdfReportService { + /// + /// Generates a detailed PDF report for a single CV verification. + /// + 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); + } + }); + } + } + }); + } + + /// + /// Generates a summary PDF report for multiple CV verifications (batch report). + /// public byte[] GenerateReport(List data) { QuestPDF.Settings.License = LicenseType.Community;