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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user