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

@@ -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)