Add multi-file upload, auto-refresh reports, and CSV export

- Allow recruiters to upload multiple CVs at once with batch processing
- Add auto-refresh polling on Report page during CV processing
- Add CSV export button on Dashboard for completed check summaries
- Update logo and reset to Bootstrap default styling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 19:15:33 +01:00
parent 4de233f56d
commit 7d6d5f12ea
12 changed files with 376 additions and 265 deletions

View File

@@ -15,6 +15,7 @@
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
@inherits LayoutComponentBase
<div class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<img src="/images/TrueCV_Logo.png" alt="TrueCV" style="height: 36px; filter: brightness(0) invert(1);" />
<img src="images/TrueCV_Logo.png" alt="TrueCV" style="height: 50px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
@@ -62,7 +62,7 @@
</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link btn btn-outline-light ms-2 px-3" href="/account/register">
<NavLink class="nav-link btn btn-outline-primary ms-2 px-3" href="/account/register">
Register
</NavLink>
</li>

View File

@@ -16,7 +16,7 @@
<div class="card border-0 shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<img src="/images/TrueCV_Logo.png" alt="TrueCV" class="mb-3" style="height: 60px;" />
<img src="images/TrueCV_Logo.png" alt="TrueCV" class="mb-3" style="height: 60px;" />
<h3 class="fw-bold">Welcome Back</h3>
<p class="text-muted">Sign in to your TrueCV account</p>
</div>

View File

@@ -18,7 +18,7 @@
<div class="card border-0 shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<img src="/images/TrueCV_Logo.png" alt="TrueCV" class="mb-3" style="height: 60px;" />
<img src="images/TrueCV_Logo.png" alt="TrueCV" class="mb-3" style="height: 60px;" />
<h3 class="fw-bold">Create Account</h3>
<p class="text-muted">Start verifying CVs with confidence</p>
</div>

View File

@@ -7,14 +7,14 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger
<PageTitle>Upload CV - TrueCV</PageTitle>
<PageTitle>Upload CVs - TrueCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="text-center mb-4">
<h1 class="fw-bold">Upload CV for Verification</h1>
<p class="text-muted lead">Upload a CV in PDF or DOCX format to begin the verification process</p>
<h1 class="fw-bold">Upload CVs for Verification</h1>
<p class="text-muted lead">Upload one or more CVs in PDF or DOCX format to begin the verification process</p>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
@@ -33,8 +33,8 @@
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Uploading...</span>
</div>
<h5 class="mb-2">Uploading your CV...</h5>
<p class="text-muted">Please wait while we process your file</p>
<h5 class="mb-2">Uploading CVs...</h5>
<p class="text-muted">Processing @_currentFileIndex of @_totalFiles files</p>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
@@ -44,6 +44,10 @@
aria-valuemax="100">
</div>
</div>
@if (!string.IsNullOrEmpty(_currentFileName))
{
<small class="text-muted mt-2 d-block">@_currentFileName</small>
}
</div>
}
else
@@ -57,6 +61,7 @@
<InputFile OnChange="HandleFileSelected"
accept=".pdf,.docx"
multiple
class="d-none"
id="fileInput" />
@@ -65,39 +70,52 @@
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
</svg>
<h5 class="mb-2">Drag and drop your CV here</h5>
<h5 class="mb-2">Drag and drop your CVs here</h5>
<p class="text-muted mb-3">or click to browse files</p>
<span class="badge bg-light text-dark">Accepted formats: PDF, DOCX</span>
<span class="badge bg-light text-dark">Accepted formats: PDF, DOCX | Multiple files supported</span>
</label>
</div>
@if (_selectedFile is not null)
@if (_selectedFiles.Count > 0)
{
<div class="mt-4 p-3 bg-light rounded d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-text text-primary me-3" viewBox="0 0 16 16">
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>
<div>
<p class="mb-0 fw-medium">@_selectedFile.Name</p>
<small class="text-muted">@FormatFileSize(_selectedFile.Size)</small>
</div>
<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Selected Files (@_selectedFiles.Count)</h6>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearAllFiles">
Clear All
</button>
</div>
<div class="list-group">
@foreach (var file in _selectedFiles)
{
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-file-earmark-text text-primary me-3" viewBox="0 0 16 16">
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>
<div>
<p class="mb-0 fw-medium small">@file.Name</p>
<small class="text-muted">@FormatFileSize(file.Size)</small>
</div>
</div>
<button class="btn btn-outline-danger btn-sm" @onclick="() => RemoveFile(file)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
</svg>
</button>
</div>
}
</div>
<button class="btn btn-outline-danger btn-sm" @onclick="ClearFile">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
</svg>
</button>
</div>
<div class="mt-4 text-center">
<button class="btn btn-primary btn-lg px-5" @onclick="UploadFile">
<button class="btn btn-primary btn-lg px-5" @onclick="UploadFiles">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload 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 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
Start Verification
Start Verification (@_selectedFiles.Count @(_selectedFiles.Count == 1 ? "CV" : "CVs"))
</button>
</div>
}
@@ -138,13 +156,17 @@
</style>
@code {
private IBrowserFile? _selectedFile;
private List<IBrowserFile> _selectedFiles = new();
private bool _isUploading;
private bool _isDragging;
private int _uploadProgress;
private string? _errorMessage;
private int _currentFileIndex;
private int _totalFiles;
private string? _currentFileName;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const int MaxFileCount = 50; // Maximum files per batch
// Magic bytes for file type validation
private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
@@ -168,43 +190,63 @@
private void HandleFileSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
var invalidFiles = new List<string>();
var oversizedFiles = new List<string>();
if (!IsValidFileType(file.Name))
foreach (var file in e.GetMultipleFiles(MaxFileCount))
{
_errorMessage = "Invalid file type. Please upload a PDF or DOCX file.";
return;
if (!IsValidFileType(file.Name))
{
invalidFiles.Add(file.Name);
continue;
}
if (file.Size > MaxFileSize)
{
oversizedFiles.Add(file.Name);
continue;
}
// Avoid duplicates
if (!_selectedFiles.Any(f => f.Name == file.Name && f.Size == file.Size))
{
_selectedFiles.Add(file);
}
}
if (file.Size > MaxFileSize)
{
_errorMessage = "File size exceeds 10MB limit.";
return;
}
// Build error message if any files were rejected
var errors = new List<string>();
if (invalidFiles.Count > 0)
errors.Add($"Invalid file type(s): {string.Join(", ", invalidFiles.Take(3))}{(invalidFiles.Count > 3 ? $" and {invalidFiles.Count - 3} more" : "")}");
if (oversizedFiles.Count > 0)
errors.Add($"File(s) exceed 10MB limit: {string.Join(", ", oversizedFiles.Take(3))}{(oversizedFiles.Count > 3 ? $" and {oversizedFiles.Count - 3} more" : "")}");
_selectedFile = file;
if (errors.Count > 0)
_errorMessage = string.Join(". ", errors);
}
private void ClearFile()
private void RemoveFile(IBrowserFile file)
{
_selectedFile = null;
_selectedFiles.Remove(file);
}
private void ClearAllFiles()
{
_selectedFiles.Clear();
_errorMessage = null;
}
private CancellationTokenSource? _progressCts;
private async Task UploadFile()
private async Task UploadFiles()
{
if (_selectedFile is null) return;
_progressCts = new CancellationTokenSource();
Task? progressTask = null;
if (_selectedFiles.Count == 0) return;
try
{
_isUploading = true;
_uploadProgress = 0;
_errorMessage = null;
_totalFiles = _selectedFiles.Count;
_currentFileIndex = 0;
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -216,61 +258,61 @@
return;
}
// Simulate progress for better UX
progressTask = SimulateProgress(_progressCts.Token);
var failedFiles = new List<string>();
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Validate file content (magic bytes)
if (!await ValidateFileContentAsync(memoryStream, _selectedFile.Name))
foreach (var file in _selectedFiles.ToList())
{
_errorMessage = "Invalid file content. The file appears to be corrupted or not a valid PDF/DOCX.";
return;
_currentFileIndex++;
_currentFileName = file.Name;
_uploadProgress = (int)((_currentFileIndex - 1) / (double)_totalFiles * 100);
await InvokeAsync(StateHasChanged);
try
{
await using var stream = file.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Validate file content (magic bytes)
if (!await ValidateFileContentAsync(memoryStream, file.Name))
{
failedFiles.Add($"{file.Name} (invalid content)");
continue;
}
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
failedFiles.Add($"{file.Name} (upload error)");
}
}
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
_uploadProgress = 100;
_currentFileName = null;
await InvokeAsync(StateHasChanged);
await Task.Delay(500); // Brief pause to show completion
NavigationManager.NavigateTo($"/report/{checkId}");
if (failedFiles.Count > 0 && failedFiles.Count < _totalFiles)
{
// Partial success - some files failed
Logger.LogWarning("Some files failed to upload: {FailedFiles}", string.Join(", ", failedFiles));
}
// Navigate to dashboard to see all uploaded CVs
NavigationManager.NavigateTo("/dashboard");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CV");
Logger.LogError(ex, "Error uploading CVs");
_errorMessage = "An error occurred while uploading. Please try again.";
}
finally
{
_isUploading = false;
_progressCts?.Cancel();
if (progressTask is not null)
{
try { await progressTask; } catch (OperationCanceledException) { }
}
_progressCts?.Dispose();
_progressCts = null;
}
}
private async Task SimulateProgress(CancellationToken cancellationToken)
{
try
{
while (_uploadProgress < 90 && _isUploading && !cancellationToken.IsCancellationRequested)
{
await Task.Delay(200, cancellationToken);
_uploadProgress += 10;
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
// Expected when upload completes
_currentFileName = null;
}
}

View File

@@ -6,6 +6,7 @@
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Dashboard> Logger
@inject IJSRuntime JSRuntime
<PageTitle>Dashboard - TrueCV</PageTitle>
@@ -15,12 +16,28 @@
<h1 class="fw-bold mb-1">Dashboard</h1>
<p class="text-muted mb-0">View and manage your CV verification checks</p>
</div>
<a href="/check" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
</svg>
New Check
</a>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="ExportToCsv" disabled="@(_isExporting || !HasCompletedChecks())">
@if (_isExporting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-1" 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>
}
Export CSV
</button>
<a href="/check" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
</svg>
New Check
</a>
</div>
</div>
@if (_isLoading)
@@ -229,7 +246,9 @@
@code {
private List<CVCheckDto> _checks = [];
private bool _isLoading = true;
private bool _isExporting;
private string? _errorMessage;
private Guid _userId;
protected override async Task OnInitializedAsync()
{
@@ -246,13 +265,13 @@
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out _userId))
{
_errorMessage = "Unable to identify user. Please log in again.";
return;
}
_checks = await CVCheckService.GetUserChecksAsync(userId);
_checks = await CVCheckService.GetUserChecksAsync(_userId);
}
catch (Exception ex)
{
@@ -282,4 +301,78 @@
_ => "bg-danger"
};
}
private async Task ExportToCsv()
{
if (_isExporting) return;
_isExporting = true;
StateHasChanged();
try
{
var csvBuilder = new System.Text.StringBuilder();
csvBuilder.AppendLine("Candidate,Upload Date,Score,Score Label,Employers Verified,Employers Unverified,Gap Months,Overlap Months,Critical Flags,Warning Flags");
foreach (var check in _checks)
{
if (check.Status != "Completed") continue;
var report = await CVCheckService.GetReportAsync(check.Id, _userId);
if (report is null) continue;
int verifiedCount = 0;
int unverifiedCount = 0;
foreach (var v in report.EmploymentVerifications)
{
if (v.IsVerified) verifiedCount++;
else unverifiedCount++;
}
int criticalFlags = 0;
int warningFlags = 0;
foreach (var f in report.Flags)
{
if (f.Severity == "Critical") criticalFlags++;
else if (f.Severity == "Warning") warningFlags++;
}
var candidateName = EscapeCsv(Path.GetFileNameWithoutExtension(check.OriginalFileName));
var uploadDate = check.CreatedAt.ToString("yyyy-MM-dd HH:mm");
csvBuilder.AppendLine(candidateName + "," + uploadDate + "," + report.OverallScore + "," + EscapeCsv(report.ScoreLabel) + "," + verifiedCount + "," + unverifiedCount + "," + report.TimelineAnalysis.TotalGapMonths + "," + report.TimelineAnalysis.TotalOverlapMonths + "," + criticalFlags + "," + warningFlags);
}
var fileName = "TrueCV_Report_Summary_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv";
await JSRuntime.InvokeVoidAsync("downloadCsvFile", fileName, csvBuilder.ToString());
}
catch (Exception ex)
{
Logger.LogError(ex, "Error exporting CSV");
}
finally
{
_isExporting = false;
StateHasChanged();
}
}
private static string EscapeCsv(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
if (field.Contains(",") || field.Contains("\"") || field.Contains("\n"))
{
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
return field;
}
private bool HasCompletedChecks()
{
foreach (var c in _checks)
{
if (c.Status == "Completed") return true;
}
return false;
}
}

View File

@@ -22,7 +22,7 @@
</div>
</div>
<div class="col-lg-6 text-center mt-4 mt-lg-0">
<img src="/images/TrueCV_Logo.png" alt="TrueCV" class="hero-logo" style="filter: brightness(0) invert(1); opacity: 0.9;" />
<img src="images/TrueCV_Logo.png" alt="TrueCV" class="hero-logo" style="max-height: 200px;" />
</div>
</div>
</div>

View File

@@ -1,11 +1,13 @@
@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
<PageTitle>Verification Report - TrueCV</PageTitle>
@@ -96,7 +98,7 @@
</p>
</div>
<div class="col-auto">
<button class="btn btn-outline-primary" @onclick="DownloadReport">
<button class="btn btn-outline-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-1" 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"/>
@@ -468,10 +470,48 @@
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()
@@ -524,9 +564,76 @@
await LoadData();
}
private void DownloadReport()
private async Task DownloadReport()
{
// TODO: Implement report download functionality
if (_report is null || _check is null) return;
try
{
var reportData = new
{
CandidateName = _check.OriginalFileName,
GeneratedAt = _report.GeneratedAt,
OverallScore = _report.OverallScore,
ScoreLabel = _report.ScoreLabel,
EmploymentVerifications = _report.EmploymentVerifications.Select(v => new
{
v.ClaimedCompany,
ClaimedStartDate = v.ClaimedStartDate?.ToString("yyyy-MM"),
ClaimedEndDate = v.ClaimedEndDate?.ToString("yyyy-MM"),
v.MatchedCompanyName,
v.MatchedCompanyNumber,
v.MatchScore,
v.IsVerified,
v.VerificationNotes
}),
TimelineAnalysis = new
{
_report.TimelineAnalysis.TotalGapMonths,
_report.TimelineAnalysis.TotalOverlapMonths,
Gaps = _report.TimelineAnalysis.Gaps.Select(g => new
{
StartDate = g.StartDate.ToString("yyyy-MM"),
EndDate = g.EndDate.ToString("yyyy-MM"),
g.Months
}),
Overlaps = _report.TimelineAnalysis.Overlaps.Select(o => new
{
o.Company1,
o.Company2,
OverlapStart = o.OverlapStart.ToString("yyyy-MM"),
OverlapEnd = o.OverlapEnd.ToString("yyyy-MM"),
o.Months
})
},
Flags = _report.Flags.Select(f => new
{
f.Severity,
f.Title,
f.Description,
f.ScoreImpact
})
};
var json = System.Text.Json.JsonSerializer.Serialize(reportData, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
var fileName = $"TrueCV_Report_{Path.GetFileNameWithoutExtension(_check.OriginalFileName)}_{DateTime.Now:yyyyMMdd}.json";
await DownloadFileAsync(fileName, json, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error downloading report");
}
}
private async Task DownloadFileAsync(string fileName, string content, string contentType)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, contentType);
}
private static string GetScoreColorClass(int score)

View File

@@ -1,4 +1,5 @@
@using System.Net.Http
@using System.IO
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization

View File

@@ -1,134 +1,29 @@
/* TrueCV Brand Colors - based on logo */
:root {
--truecv-primary: #2B5F9E;
--truecv-primary-dark: #1E4A7A;
--truecv-primary-light: #3A7BC8;
--truecv-secondary: #F5F5F0;
--truecv-accent: #4A90D9;
--truecv-text: #1a1a1a;
--truecv-text-muted: #6c757d;
}
html, body {
font-family: 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
/* Override Bootstrap primary color */
.bg-primary {
background-color: var(--truecv-primary) !important;
}
.text-primary {
color: var(--truecv-primary) !important;
}
.border-primary {
border-color: var(--truecv-primary) !important;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: var(--truecv-primary);
}
a:hover {
color: var(--truecv-primary-dark);
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: var(--truecv-primary);
border-color: var(--truecv-primary);
}
.btn-primary:hover, .btn-primary:focus {
background-color: var(--truecv-primary-dark);
border-color: var(--truecv-primary-dark);
}
.btn-primary:active {
background-color: var(--truecv-primary-dark) !important;
border-color: var(--truecv-primary-dark) !important;
}
.btn-outline-primary {
color: var(--truecv-primary);
border-color: var(--truecv-primary);
}
.btn-outline-primary:hover {
background-color: var(--truecv-primary);
border-color: var(--truecv-primary);
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem var(--truecv-accent);
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
/* Navbar styling */
.navbar-brand {
display: flex;
align-items: center;
}
.navbar-brand img {
height: 40px;
width: auto;
}
.navbar-nav .nav-link.active {
font-weight: 600;
border-bottom: 2px solid white;
}
/* Card styling */
.card {
border-radius: 0.5rem;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15);
}
/* Feature cards */
.bg-primary.bg-opacity-10 {
background-color: rgba(43, 95, 158, 0.1) !important;
}
/* Form styling */
.form-control:focus {
border-color: var(--truecv-primary);
box-shadow: 0 0 0 0.2rem rgba(43, 95, 158, 0.25);
}
.form-check-input:checked {
background-color: var(--truecv-primary);
border-color: var(--truecv-primary);
}
/* Content padding */
.content {
padding-top: 1.5rem;
}
@media (min-width: 768px) {
.content {
padding-top: 2rem;
}
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
h1:focus-visible {
outline: 2px solid var(--truecv-primary);
outline-offset: 2px;
}
/* Validation styling */
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
@@ -141,65 +36,16 @@ h1:focus-visible {
color: #e50000;
}
/* Blazor error boundary */
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
/* Veracity score colors */
.score-excellent {
color: #198754;
}
.score-good {
color: #20c997;
}
.score-fair {
color: #ffc107;
}
.score-poor {
color: #fd7e14;
}
.score-very-poor {
color: #dc3545;
}
/* Page header styling */
.page-header {
background: linear-gradient(135deg, var(--truecv-primary) 0%, var(--truecv-primary-dark) 100%);
}
/* Footer styling */
footer {
background-color: var(--truecv-primary-dark) !important;
}
/* Login/Register card styling */
.auth-card {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
/* Logo in navbar - for light background variant */
.logo-dark {
filter: brightness(0) saturate(100%) invert(29%) sepia(68%) saturate(487%) hue-rotate(178deg) brightness(93%) contrast(91%);
}
/* Hero section logo */
.hero-logo {
max-width: 350px;
height: auto;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,21 @@
// TrueCV JavaScript utilities
function downloadFile(fileName, base64Content, contentType) {
const link = document.createElement('a');
link.download = fileName;
link.href = `data:${contentType};base64,${base64Content}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadCsvFile(fileName, csvContent) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}