Files
RealCV/src/RealCV.Web/Components/Pages/Report.razor
Peter Foster 0c42842655 feat: Add legal compliance changes
- Replace 'diploma mill' language with objective 'unaccredited institution' terminology
- Rename DiplomaMills.cs to UnaccreditedInstitutions.cs with neutral language
- Update EducationVerificationResult.IsUnaccredited property
- Update flag titles to 'Unaccredited Institution' and 'Institution Requires Verification'
- Add legal disclaimer to verification report page
- Add Privacy Policy page (/privacy) with UK GDPR compliance info
- Add Terms of Service page (/terms) with candidate notice requirements
- Add footer links to Privacy and Terms pages
- Update all tests to use new terminology

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 04:52:12 +00:00

1091 lines
51 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/report/{Id:guid}"
@attribute [Authorize]
@rendermode InteractiveServer
@implements IDisposable
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Report> Logger
@inject IJSRuntime JSRuntime
@inject IAuditService AuditService
@inject IPdfReportService PdfReportService
<PageTitle>Verification Report - RealCV</PageTitle>
<div class="container py-5">
@if (_isLoading)
{
<div class="text-center py-5">
<div class="loading-placeholder">
<div class="placeholder-glow mb-4">
<div class="placeholder col-12 rounded-4" style="height: 180px;"></div>
</div>
<div class="placeholder-glow mb-3">
<div class="placeholder col-12 rounded-4" style="height: 280px;"></div>
</div>
</div>
<div class="mt-4">
<div class="spinner-border text-primary" role="status" style="width: 2.5rem; height: 2.5rem;">
<span class="visually-hidden">Loading report...</span>
</div>
<p class="mt-3 text-muted fw-medium">Loading verification report...</p>
</div>
</div>
}
else if (_errorMessage is not null)
{
<div class="alert alert-danger" role="alert">
@_errorMessage
</div>
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
}
else if (_check is not null && _check.Status != "Completed")
{
<div class="text-center py-5">
<div class="card border-0 shadow-sm mx-auto" style="max-width: 500px;">
<div class="card-body p-5">
@if (_check.Status == "Processing")
{
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Processing...</span>
</div>
<h4 class="mb-2">Processing Your CV</h4>
<p class="text-muted mb-4">Our AI is analysing the document. This usually takes 1-2 minutes.</p>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 60%">
</div>
</div>
}
else if (_check.Status == "Pending")
{
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hourglass-split text-warning mb-3" viewBox="0 0 16 16">
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
</svg>
<h4 class="mb-2">Queued for Processing</h4>
<p class="text-muted">Your CV is in the queue and will be processed shortly.</p>
}
else if (_check.Status == "Failed")
{
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-exclamation-triangle text-danger mb-3" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<h4 class="mb-2">Processing Failed</h4>
<p class="text-muted">We encountered an error processing your CV. Please try uploading again.</p>
}
<p class="text-muted small mt-4">
File: @_check.OriginalFileName<br />
Uploaded: @_check.CreatedAt.ToString("dd MMM yyyy HH:mm")
</p>
<button class="btn btn-outline-primary mt-3" @onclick="RefreshStatus">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Refresh Status
</button>
</div>
</div>
</div>
}
else if (_report is not null && _check is not null)
{
<!-- Report Header -->
<div class="page-header mb-4">
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/dashboard" class="text-decoration-none">Dashboard</a></li>
<li class="breadcrumb-item active text-muted" aria-current="page">Report</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<h1 class="fw-bold mb-1">Verification Report</h1>
@if (!string.IsNullOrWhiteSpace(_report.CandidateName))
{
<h2 class="h4 text-primary mb-2">@_report.CandidateName</h2>
}
<p class="text-muted mb-0">
<span class="fw-medium text-dark">@_check.OriginalFileName</span>
<span class="mx-2">|</span>
Generated @_report.GeneratedAt.ToString("dd MMM yyyy") at @_report.GeneratedAt.ToString("HH:mm")
</p>
</div>
<button class="btn btn-primary" @onclick="DownloadReport" disabled="@(_report is null)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-2" 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>
Download PDF
</button>
</div>
</div>
<!-- Score Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm overflow-hidden">
<div class="score-header">
<div class="row align-items-center py-2 px-3">
<div class="col-md-4 text-center">
<div class="score-roundel @GetScoreColorClass(_report!.OverallScore)">
<svg class="score-ring" viewBox="0 0 120 120">
<circle class="score-ring-bg" cx="60" cy="60" r="54" />
@{
var circumference = 339.292;
var progressLength = circumference * _report.OverallScore / 100;
var gapLength = circumference - progressLength;
}
@if (_report.OverallScore > 0)
{
<circle class="score-ring-progress" cx="60" cy="60" r="54"
stroke-dasharray="@progressLength @gapLength"
stroke-dashoffset="0" />
}
</svg>
<div class="score-value-container">
<span class="score-value">@_report.OverallScore</span>
<span class="score-max">/100</span>
</div>
</div>
<div class="mt-1 text-white truecv-score-label">RealCV Score</div>
</div>
<div class="col-md-8">
<div class="row g-2 text-center text-md-start">
<div class="col-4">
<div class="stat-item">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Z"/>
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z"/>
</svg>
</div>
<h5 class="mb-0 fw-bold text-white">@_report.EmploymentVerifications.Count</h5>
<small class="stat-label">Employers Checked</small>
</div>
</div>
<div class="col-4">
<div class="stat-item">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M6.146 7.146a.5.5 0 0 1 .708 0L8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 0 1 0-.708z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>
</div>
<h5 class="mb-0 fw-bold text-white">@_report.TimelineAnalysis.TotalGapMonths</h5>
<small class="stat-label">Gap Months</small>
</div>
</div>
<div class="col-4">
<div class="stat-item">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/>
</svg>
</div>
<h5 class="mb-0 fw-bold text-white">@_report.Flags.Count</h5>
<small class="stat-label">Flags Raised</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Employment Verification -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header py-3" style="background-color: var(--realcv-bg-surface);">
<h5 class="mb-0 fw-bold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-building me-2" viewBox="0 0 16 16">
<path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM10.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z"/>
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z"/>
</svg>
Employment Verification
</h5>
</div>
<div class="card-body p-0">
<div class="employment-list">
<div class="employment-header">
<div style="text-align: center;"></div>
<div>Employer</div>
<div style="text-align: center;">Period</div>
<div style="text-align: center;">Match</div>
<div style="text-align: center;">Pts</div>
</div>
@for (int i = 0; i < _report.EmploymentVerifications.Count; i++)
{
var verification = _report.EmploymentVerifications[i];
var index = i;
var companyPoints = GetPointsForCompany(verification.ClaimedCompany, verification.MatchedCompanyName, index);
<div class="employment-row @(verification.IsVerified ? "employment-row-verified" : "employment-row-unverified")">
<div class="employment-status-icon">
@if (verification.IsVerified)
{
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
}
</div>
<div class="employment-company">
<span class="employment-company-name">@verification.ClaimedCompany</span>
@if (!string.IsNullOrEmpty(verification.VerificationNotes))
{
<span class="employment-note-inline">@verification.VerificationNotes</span>
}
</div>
<div class="employment-dates">
@if (verification.ClaimedStartDate.HasValue)
{
<span>@verification.ClaimedStartDate.Value.ToString("MMM yyyy") @(verification.ClaimedEndDate?.ToString("MMM yyyy") ?? "Present")</span>
}
else
{
<span class="text-muted">—</span>
}
</div>
<div class="employment-score">
<span class="badge @GetMatchScoreBadgeClass(verification.MatchScore)">@verification.MatchScore%</span>
</div>
<div class="employment-points">
@if (companyPoints < 0)
{
<span class="text-danger fw-medium">@companyPoints</span>
}
else if (!verification.IsVerified && !_firstOccurrenceIndices.Contains(index))
{
<span class="text-muted" title="Penalty counted on first occurrence">—</span>
}
else
{
<span class="text-success">0</span>
}
</div>
</div>
}
</div>
</div>
</div>
<!-- Timeline Analysis -->
<div class="row mb-4">
<!-- Gaps -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header py-3" style="background-color: var(--realcv-bg-surface);">
<h5 class="mb-0 fw-bold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-clock-history me-2 text-warning" viewBox="0 0 16 16">
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
</svg>
Employment Gaps
</h5>
</div>
<div class="card-body">
@if (_report.TimelineAnalysis.Gaps.Count > 0)
{
<ul class="list-group list-group-flush">
@foreach (var gap in _report.TimelineAnalysis.Gaps)
{
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span>
@gap.StartDate.ToString("MMM yyyy") - @gap.EndDate.ToString("MMM yyyy")
</span>
<span class="badge bg-warning text-dark rounded-pill">@gap.Months months</span>
</li>
}
</ul>
<div class="mt-3 p-2 bg-light rounded">
<small class="text-muted">Total gap time: <strong>@_report.TimelineAnalysis.TotalGapMonths months</strong></small>
</div>
}
else
{
<div class="text-center py-4">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-check-circle text-success mb-2" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
<p class="mb-0 text-muted">No significant gaps detected</p>
</div>
}
</div>
</div>
</div>
<!-- Overlaps -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header py-3" style="background-color: var(--realcv-bg-surface);">
<h5 class="mb-0 fw-bold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-intersect me-2 text-danger" viewBox="0 0 16 16">
<path d="M0 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2H2a2 2 0 0 1-2-2V2zm5 10v2a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2v5a2 2 0 0 1-2 2H5zm6-8V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2V6a2 2 0 0 1 2-2h5z"/>
</svg>
Timeline Overlaps
</h5>
</div>
<div class="card-body">
@if (_report.TimelineAnalysis.Overlaps.Count > 0)
{
<ul class="list-group list-group-flush">
@foreach (var overlap in _report.TimelineAnalysis.Overlaps)
{
<li class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<small class="text-muted">@overlap.Company1</small> &amp;
<small class="text-muted">@overlap.Company2</small>
</div>
<span class="badge bg-danger rounded-pill">@overlap.Months months</span>
</div>
<small class="text-muted">
@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy")
</small>
</li>
}
</ul>
<div class="mt-3 p-2 bg-light rounded">
<small class="text-muted">Total overlap time: <strong>@_report.TimelineAnalysis.TotalOverlapMonths months</strong></small>
</div>
}
else
{
<div class="text-center py-4">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-check-circle text-success mb-2" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
<p class="mb-0 text-muted">No overlapping positions detected</p>
</div>
}
</div>
</div>
</div>
</div>
<!-- Flags -->
@if (_report.Flags.Count > 0)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header py-3" style="background-color: var(--realcv-bg-surface);">
<h5 class="mb-0 fw-bold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-flag me-2 text-danger" viewBox="0 0 16 16">
<path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/>
</svg>
Flags Raised
</h5>
</div>
<div class="card-body">
@{
// Deduplicate flags and fix any variable-style names
var uniqueFlags = _report.Flags
.GroupBy(f => (f.Title, f.Description))
.Select(g => g.First())
.Select(f => new {
f.Severity,
Title = FormatFlagTitle(f.Title),
f.Description,
ScoreImpact = Math.Abs(f.ScoreImpact)
})
.ToList();
var criticalFlags = uniqueFlags.Where(f => f.Severity == "Critical").ToList();
var warningFlags = uniqueFlags.Where(f => f.Severity == "Warning").ToList();
var infoFlags = uniqueFlags.Where(f => f.Severity == "Info").ToList();
}
@if (criticalFlags.Count > 0)
{
<h6 class="text-danger fw-bold mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-octagon me-1" viewBox="0 0 16 16">
<path d="M4.54.146A.5.5 0 0 1 4.893 0h6.214a.5.5 0 0 1 .353.146l4.394 4.394a.5.5 0 0 1 .146.353v6.214a.5.5 0 0 1-.146.353l-4.394 4.394a.5.5 0 0 1-.353.146H4.893a.5.5 0 0 1-.353-.146L.146 11.46A.5.5 0 0 1 0 11.107V4.893a.5.5 0 0 1 .146-.353L4.54.146zM5.1 1 1 5.1v5.8L5.1 15h5.8l4.1-4.1V5.1L10.9 1H5.1z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
</svg>
Critical Issues
</h6>
@foreach (var flag in criticalFlags)
{
<div class="flag-item flag-critical">
<div class="d-flex justify-content-between align-items-start">
<strong class="flag-title">@flag.Title</strong>
<span class="flag-points bg-danger-subtle text-danger">-@flag.ScoreImpact pts</span>
</div>
<p class="flag-description">@flag.Description</p>
</div>
}
}
@if (warningFlags.Count > 0)
{
<h6 class="text-warning fw-bold mb-3 @(criticalFlags.Count > 0 ? "mt-4" : "")">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle me-1" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
Warnings
</h6>
@foreach (var flag in warningFlags)
{
<div class="flag-item flag-warning">
<div class="d-flex justify-content-between align-items-start">
<strong class="flag-title">@flag.Title</strong>
<span class="flag-points bg-warning-subtle text-warning">-@flag.ScoreImpact pts</span>
</div>
<p class="flag-description">@flag.Description</p>
</div>
}
}
@if (infoFlags.Count > 0)
{
<h6 class="text-primary fw-bold mb-3 @(criticalFlags.Count > 0 || warningFlags.Count > 0 ? "mt-4" : "")">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
Information
</h6>
@foreach (var flag in infoFlags)
{
<div class="flag-item flag-info">
<strong class="flag-title">@flag.Title</strong>
<p class="flag-description">@flag.Description</p>
</div>
}
}
</div>
</div>
}
<!-- Legal Disclaimer -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h6 class="text-muted mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle me-2" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
Important Information
</h6>
<div class="small text-muted">
<p class="mb-2">
<strong>This report is for informational purposes only.</strong> The verification results are based on
publicly available data from Companies House and other official sources. This analysis should be
used as one input among many in your hiring decision-making process.
</p>
<p class="mb-2">
<strong>Limitations:</strong> This automated verification cannot confirm whether a specific individual
actually worked at a verified company, only that the company exists and was active during the claimed
employment period. Education verification is based on institutional recognition status only.
</p>
<p class="mb-2">
<strong>Not a substitute for thorough background checks:</strong> We recommend supplementing this
report with direct reference checks, qualification verification with issuing institutions, and
other appropriate due diligence measures.
</p>
<p class="mb-0">
<strong>Candidate rights:</strong> Data subjects have the right to request access to, correction of,
or deletion of their personal data. For enquiries, please contact us via our website.
</p>
</div>
</div>
</div>
}
</div>
<style>
/* Score Header - Blue gradient */
.score-header {
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
color: white;
position: relative;
overflow: hidden;
}
.score-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.06'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
}
/* Score Roundel */
.score-roundel {
position: relative;
width: 100px;
height: 100px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 6px;
}
.score-roundel .score-ring {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.score-roundel .score-ring-bg {
fill: none;
stroke-width: 8;
}
.score-roundel .score-ring-progress {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transform-origin: center;
transition: stroke-dasharray 0.6s ease;
}
/* Score color variants - colours both the background ring and progress */
.score-roundel.score-high .score-ring-bg {
stroke: rgba(255, 255, 255, 0.3);
}
.score-roundel.score-high .score-ring-progress {
stroke: #4ade80;
}
.score-roundel.score-medium .score-ring-bg {
stroke: rgba(255, 255, 255, 0.3);
}
.score-roundel.score-medium .score-ring-progress {
stroke: #fbbf24;
}
.score-roundel.score-low .score-ring-bg {
stroke: rgba(255, 255, 255, 0.3);
}
.score-roundel.score-low .score-ring-progress {
stroke: #f87171;
}
.score-roundel .score-value-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.score-roundel .score-value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
color: white;
}
.score-roundel .score-max {
font-size: 0.75rem;
opacity: 0.85;
color: white;
}
.truecv-score-label {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 0.875rem;
letter-spacing: 0.025em;
}
/* Stat Items */
.stat-item {
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-icon {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
color: white;
}
.stat-label {
color: rgba(255, 255, 255, 0.95);
font-size: 0.875rem;
}
/* Flag Items */
.flag-item {
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border-left: 4px solid;
}
.flag-item.flag-critical {
background-color: #fdf2f2;
border-left-color: #dc2626;
}
.flag-item.flag-warning {
background-color: #fdfbf0;
border-left-color: #d97706;
}
.flag-item.flag-info {
background-color: #f0f5fa;
border-left-color: var(--realcv-primary);
}
.flag-title {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--realcv-gray-700);
}
.flag-description {
color: #4b5563;
font-size: 0.875rem;
margin: 0.5rem 0 0 0;
}
.flag-points {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
/* Employment List - Compact Row Layout */
.employment-list {
border-top: 1px solid #e5e7eb;
}
.employment-header {
display: grid;
grid-template-columns: 24px 1fr 120px 60px 50px;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background-color: #f8fafc;
border-bottom: 1px solid #e5e7eb;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.025em;
align-items: center;
}
.employment-header div {
display: flex;
align-items: center;
}
.employment-header div:nth-child(3),
.employment-header div:nth-child(4),
.employment-header div:nth-child(5) {
justify-content: center;
text-align: center;
}
.employment-row {
display: grid;
grid-template-columns: 24px 1fr 120px 60px 50px;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.15s ease;
}
.employment-row:hover {
background-color: #f9fafb;
}
.employment-row-verified {
border-left: 3px solid #22c55e;
}
.employment-row-unverified {
border-left: 3px solid #f59e0b;
}
.employment-status-icon {
display: flex;
align-items: center;
justify-content: center;
}
.employment-company {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.125rem;
min-width: 0;
}
.employment-company-name {
font-weight: 600;
font-size: 0.8125rem;
color: #1f2937;
}
.employment-note-inline {
font-size: 0.75rem;
color: #4b5563;
line-height: 1.4;
}
.employment-dates {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
color: #4b5563;
white-space: nowrap;
text-align: center;
}
.employment-score {
display: flex;
align-items: center;
justify-content: center;
}
.employment-points {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
}
/* Mobile Responsiveness */
@@media (max-width: 768px) {
.score-header .row {
flex-direction: column;
}
.score-header .col-md-4 {
border-right: none !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.stat-item h3 {
font-size: 1.5rem;
}
.score-roundel {
width: 120px;
height: 120px;
}
.score-roundel .score-value {
font-size: 2rem;
}
.table-responsive {
font-size: 0.875rem;
}
.page-header .d-flex {
flex-direction: column;
}
.page-header .btn {
width: 100%;
}
.employment-row {
grid-template-columns: 20px 1fr 50px;
gap: 0.25rem 0.5rem;
padding: 0.5rem;
}
.employment-dates {
display: none;
}
.employment-points {
display: none;
}
.employment-note-inline {
display: none;
}
}
</style>
@code {
[Parameter]
public Guid Id { get; set; }
private CVCheckDto? _check;
private VeracityReport? _report;
private bool _isLoading = true;
private string? _errorMessage;
private Guid _userId;
private System.Threading.Timer? _pollingTimer;
private bool _isPolling;
protected override async Task OnInitializedAsync()
{
await LoadData();
StartPollingIfNeeded();
}
private void StartPollingIfNeeded()
{
if (_check is not null && (_check.Status == "Processing" || _check.Status == "Pending") && !_isPolling)
{
_isPolling = true;
_pollingTimer = new System.Threading.Timer(async _ =>
{
await InvokeAsync(async () =>
{
await LoadData();
// Stop polling if processing is complete or failed
if (_check is null || _check.Status == "Completed" || _check.Status == "Failed")
{
StopPolling();
}
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
}
}
private void StopPolling()
{
_isPolling = false;
_pollingTimer?.Dispose();
_pollingTimer = null;
}
public void Dispose()
{
StopPolling();
}
private async Task LoadData()
{
_isLoading = true;
_errorMessage = null;
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out _userId))
{
_errorMessage = "Unable to identify user. Please log in again.";
return;
}
_check = await CVCheckService.GetCheckForUserAsync(Id, _userId);
if (_check is null)
{
_errorMessage = "Report not found or you don't have access to view it.";
return;
}
if (_check.Status == "Completed")
{
_report = await CVCheckService.GetReportAsync(Id, _userId);
if (_report is null)
{
_errorMessage = "Unable to load the report data.";
}
else
{
ComputeFirstOccurrences(); // Pre-compute which companies are first occurrences
await AuditService.LogAsync(_userId, AuditActions.ReportViewed, "CVCheck", Id, $"Score: {_report.OverallScore}");
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading report data");
_errorMessage = "An error occurred while loading the report. Please try again.";
}
finally
{
_isLoading = false;
}
}
private async Task RefreshStatus()
{
await LoadData();
}
private async Task DownloadReport()
{
if (_report is null || _check is null) return;
try
{
var candidateName = Path.GetFileNameWithoutExtension(_check.OriginalFileName);
var pdfBytes = PdfReportService.GenerateSingleReport(candidateName, _report);
var fileName = $"RealCV_Report_{candidateName}_{DateTime.Now:yyyyMMdd}.pdf";
await DownloadFileAsync(fileName, pdfBytes, "application/pdf");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error downloading report");
}
}
private async Task DownloadFileAsync(string fileName, byte[] bytes, string contentType)
{
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, contentType);
}
private static string GetScoreColorClass(int score)
{
return score switch
{
> 70 => "score-high",
>= 50 => "score-medium",
_ => "score-low"
};
}
private static string GetMatchScoreBadgeClass(int score)
{
return score switch
{
>= 80 => "bg-success",
>= 50 => "bg-warning text-dark",
_ => "bg-danger"
};
}
private static string FormatFlagTitle(string title)
{
// Convert variable-style names to readable sentences
return title switch
{
"UnverifiedDirectorClaim" => "Unverified Director Claim",
"EmploymentBeforeIncorporation" => "Employment Before Company Existed",
"EmploymentAtDissolvedCompany" => "Employment at Dissolved Company",
"CurrentEmploymentAtDissolvedCompany" => "Current Employment at Dissolved Company",
"EmploymentAtDormantCompany" => "Employment at Dormant Company",
"SeniorRoleAtMicroCompany" => "Senior Role at Micro Company",
"SicCodeMismatch" => "Role/Industry Mismatch",
"ImplausibleJobTitle" => "Implausible Job Title",
_ => title
};
}
// Lookup for first occurrence of each sequential group of the same company (pre-computed when report loads)
private HashSet<int> _firstOccurrenceIndices = new();
private void ComputeFirstOccurrences()
{
_firstOccurrenceIndices.Clear();
if (_report?.EmploymentVerifications is null) return;
// Only mark as duplicate if the PREVIOUS entry (sequential) is the same company
// Non-sequential entries of the same company should each count separately
for (int i = 0; i < _report.EmploymentVerifications.Count; i++)
{
var currentCompany = _report.EmploymentVerifications[i].ClaimedCompany;
if (i == 0)
{
// First entry is always a first occurrence
_firstOccurrenceIndices.Add(i);
}
else
{
var previousCompany = _report.EmploymentVerifications[i - 1].ClaimedCompany;
// Only skip if same company as immediately preceding entry
if (!string.Equals(currentCompany, previousCompany, StringComparison.OrdinalIgnoreCase))
{
_firstOccurrenceIndices.Add(i);
}
}
}
}
private int GetPointsForCompany(string claimedCompany, string? matchedCompany, int index)
{
if (_report?.Flags is null || _report?.EmploymentVerifications is null) return 0;
// Only show points for the first occurrence of each company
if (!_firstOccurrenceIndices.Contains(index))
{
return 0;
}
var totalPoints = 0;
// Get the verification result for this company
var verification = _report.EmploymentVerifications.ElementAtOrDefault(index);
// Check if there's an "Unverified Company" flag for this company
// Search by both title pattern and description containing company name
var companyFlags = _report.Flags
.Where(f => f.ScoreImpact < 0 &&
((!string.IsNullOrEmpty(f.Description) && f.Description.Contains(claimedCompany, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(matchedCompany) && !string.IsNullOrEmpty(f.Description) && f.Description.Contains(matchedCompany, StringComparison.OrdinalIgnoreCase))))
.GroupBy(f => (f.Title, f.Description))
.Select(g => g.First())
.ToList();
totalPoints = companyFlags.Sum(f => f.ScoreImpact);
// If company is unverified but no flag was found in description search,
// check if there's a generic "Unverified Company" flag that might use different wording
if (verification != null && !verification.IsVerified && totalPoints == 0)
{
// Look for any Unverified Company flag that might apply
var unverifiedFlag = _report.Flags
.FirstOrDefault(f => f.Title == "Unverified Company" && f.ScoreImpact < 0 &&
!string.IsNullOrEmpty(f.Description) &&
f.Description.Contains(claimedCompany, StringComparison.OrdinalIgnoreCase));
if (unverifiedFlag != null)
{
totalPoints = unverifiedFlag.ScoreImpact;
}
else
{
// Company is unverified but no specific flag found - show the standard penalty
totalPoints = -10;
}
}
return totalPoints;
}
}