Files
RealCV/src/TrueCV.Web/Components/Pages/Report.razor
Peter Foster 21a95a38f5 Improve text readability and fix duplicate company scoring
- Increase font sizes from 11px to 12px for employment headers and notes
- Improve color contrast (gray-500 to gray-600) for WCAG AA compliance
- Increase opacity for white text on dark backgrounds (0.6/0.8 to 0.8/0.9)
- Fix duplicate company penalty display to only apply for sequential entries
- Non-sequential entries of same company now each show their own penalty

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:30:47 +00:00

1057 lines
49 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 - TrueCV</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-4 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-2 text-white truecv-score-label">TrueCV Score</div>
</div>
<div class="col-md-8">
<div class="row g-4 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>
<h3 class="mb-0 fw-bold text-white">@_report.EmploymentVerifications.Count</h3>
<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>
<h3 class="mb-0 fw-bold text-white">@_report.TimelineAnalysis.TotalGapMonths</h3>
<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>
<h3 class="mb-0 fw-bold text-white">@_report.Flags.Count</h3>
<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(--truecv-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(--truecv-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(--truecv-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(--truecv-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>
}
}
</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: 140px;
height: 140px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 8px;
}
.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: 2.5rem;
font-weight: 700;
line-height: 1;
color: white;
}
.score-roundel .score-max {
font-size: 1rem;
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(--truecv-primary);
}
.flag-title {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--truecv-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 = $"TrueCV_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;
}
}