Files
RealCV/src/RealCV.Web/Components/Pages/Report.razor

1091 lines
51 KiB
Plaintext
Raw Normal View History

@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;
}
}