From 7d6d5f12ea2b6941a2826ae82766835ed6b59476 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 20 Jan 2026 19:15:33 +0100 Subject: [PATCH] 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 --- src/TrueCV.Web/Components/App.razor | 1 + .../Components/Layout/MainLayout.razor | 6 +- .../Components/Pages/Account/Login.razor | 2 +- .../Components/Pages/Account/Register.razor | 2 +- src/TrueCV.Web/Components/Pages/Check.razor | 210 +++++++++++------- .../Components/Pages/Dashboard.razor | 109 ++++++++- src/TrueCV.Web/Components/Pages/Home.razor | 2 +- src/TrueCV.Web/Components/Pages/Report.razor | 113 +++++++++- src/TrueCV.Web/Components/_Imports.razor | 3 +- src/TrueCV.Web/wwwroot/app.css | 172 +------------- src/TrueCV.Web/wwwroot/images/TrueCV_Logo.png | Bin 4549875 -> 16476 bytes src/TrueCV.Web/wwwroot/js/app.js | 21 ++ 12 files changed, 376 insertions(+), 265 deletions(-) create mode 100644 src/TrueCV.Web/wwwroot/js/app.js diff --git a/src/TrueCV.Web/Components/App.razor b/src/TrueCV.Web/Components/App.razor index b836362..f412766 100644 --- a/src/TrueCV.Web/Components/App.razor +++ b/src/TrueCV.Web/Components/App.razor @@ -15,6 +15,7 @@ + diff --git a/src/TrueCV.Web/Components/Layout/MainLayout.razor b/src/TrueCV.Web/Components/Layout/MainLayout.razor index 446b08c..6e71631 100644 --- a/src/TrueCV.Web/Components/Layout/MainLayout.razor +++ b/src/TrueCV.Web/Components/Layout/MainLayout.razor @@ -1,10 +1,10 @@ @inherits LayoutComponentBase
-
-
} @@ -138,13 +156,17 @@ @code { - private IBrowserFile? _selectedFile; + private List _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(); + var oversizedFiles = new List(); - 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(); + 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(); - 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; } } diff --git a/src/TrueCV.Web/Components/Pages/Dashboard.razor b/src/TrueCV.Web/Components/Pages/Dashboard.razor index 0568085..8ad2bd4 100644 --- a/src/TrueCV.Web/Components/Pages/Dashboard.razor +++ b/src/TrueCV.Web/Components/Pages/Dashboard.razor @@ -6,6 +6,7 @@ @inject NavigationManager NavigationManager @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILogger Logger +@inject IJSRuntime JSRuntime Dashboard - TrueCV @@ -15,12 +16,28 @@

Dashboard

View and manage your CV verification checks

- - - - - New Check - +
+ + + + + + New Check + +
@if (_isLoading) @@ -229,7 +246,9 @@ @code { private List _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; + } } diff --git a/src/TrueCV.Web/Components/Pages/Home.razor b/src/TrueCV.Web/Components/Pages/Home.razor index 80c9700..d8c160c 100644 --- a/src/TrueCV.Web/Components/Pages/Home.razor +++ b/src/TrueCV.Web/Components/Pages/Home.razor @@ -22,7 +22,7 @@
- +
diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor index 019c3ba..604007d 100644 --- a/src/TrueCV.Web/Components/Pages/Report.razor +++ b/src/TrueCV.Web/Components/Pages/Report.razor @@ -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 Logger +@inject IJSRuntime JSRuntime Verification Report - TrueCV @@ -96,7 +98,7 @@

-