Add bulk deletion with checkboxes to Dashboard
- Add checkbox column with Select All in header - Show selection count badge and Delete Selected button - Enhanced confirmation modal for bulk operations - Row highlighting for selected items - Fixed button spacing (gap between View and Delete) - Scrollable list in modal for many selected items Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,15 +153,38 @@
|
|||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header py-3 border-bottom" style="background-color: var(--truecv-bg-surface);">
|
<div class="card-header py-3 border-bottom" style="background-color: var(--truecv-bg-surface);">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
<h5 class="mb-0 fw-bold">Recent CV Checks</h5>
|
<h5 class="mb-0 fw-bold">Recent CV Checks</h5>
|
||||||
|
@if (_selectedIds.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary">@_selectedIds.Count selected</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
@if (_selectedIds.Count > 0)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="ConfirmDeleteSelected">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3 me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
||||||
|
</svg>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<span class="badge bg-light text-muted">@_checks.Count total</span>
|
<span class="badge bg-light text-muted">@_checks.Count total</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background-color: var(--truecv-bg-muted);">
|
<tr style="background-color: var(--truecv-bg-muted);">
|
||||||
<th class="border-0 ps-4 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th>
|
<th class="border-0 ps-3 py-3" style="width: 40px;">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
checked="@IsAllSelected()"
|
||||||
|
@onchange="ToggleSelectAll"
|
||||||
|
title="Select all" />
|
||||||
|
</th>
|
||||||
|
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th>
|
||||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th>
|
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th>
|
||||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th>
|
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th>
|
||||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th>
|
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th>
|
||||||
@@ -171,9 +194,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var check in _checks)
|
@foreach (var check in _checks)
|
||||||
{
|
{
|
||||||
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "")"
|
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "") @(_selectedIds.Contains(check.Id) ? "table-active" : "")"
|
||||||
@onclick="() => ViewReport(check)">
|
@onclick="() => ViewReport(check)">
|
||||||
<td class="ps-4 py-3">
|
<td class="ps-3 py-3" @onclick:stopPropagation="true">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
checked="@_selectedIds.Contains(check.Id)"
|
||||||
|
@onchange="() => ToggleSelection(check.Id)" />
|
||||||
|
</td>
|
||||||
|
<td class="py-3">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="file-icon-wrapper me-3">
|
<div class="file-icon-wrapper me-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16">
|
||||||
@@ -245,7 +273,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 pe-4 text-end">
|
<td class="py-3 pe-4 text-end">
|
||||||
<div class="btn-group" role="group">
|
<div class="d-flex justify-content-end align-items-center gap-2">
|
||||||
@if (check.Status == "Completed")
|
@if (check.Status == "Completed")
|
||||||
{
|
{
|
||||||
<a href="/report/@check.Id" class="btn btn-sm btn-primary" @onclick:stopPropagation="true">
|
<a href="/report/@check.Id" class="btn btn-sm btn-primary" @onclick:stopPropagation="true">
|
||||||
@@ -264,7 +292,7 @@
|
|||||||
Retry
|
Retry
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteCheck(check.Id)" @onclick:stopPropagation="true" title="Delete">
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(check)" @onclick:stopPropagation="true" title="Delete">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
|
||||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -280,6 +308,68 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
@if (_showDeleteModal)
|
||||||
|
{
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle text-danger me-2" 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>
|
||||||
|
Delete @(_isBulkDelete ? $"{_checksToDelete.Count} CV Checks" : "CV Check")
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_isBulkDelete)
|
||||||
|
{
|
||||||
|
<p class="mb-2">Are you sure you want to delete <strong>@_checksToDelete.Count</strong> CV checks?</p>
|
||||||
|
<div class="bg-light rounded p-3" style="max-height: 200px; overflow-y: auto;">
|
||||||
|
@foreach (var check in _checksToDelete)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-1 @(check != _checksToDelete.Last() ? "border-bottom" : "")">
|
||||||
|
<span>@Path.GetFileNameWithoutExtension(check.OriginalFileName)</span>
|
||||||
|
<small class="text-muted">@check.CreatedAt.ToString("dd MMM yyyy")</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_checkToDelete != null)
|
||||||
|
{
|
||||||
|
<p class="mb-2">Are you sure you want to delete this CV check?</p>
|
||||||
|
<div class="bg-light rounded p-3">
|
||||||
|
<strong>@Path.GetFileNameWithoutExtension(_checkToDelete.OriginalFileName)</strong>
|
||||||
|
<br />
|
||||||
|
<small class="text-muted">Uploaded @_checkToDelete.CreatedAt.ToString("dd MMM yyyy 'at' HH:mm")</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<p class="text-danger small mt-3 mb-0">
|
||||||
|
<strong>This action cannot be undone.</strong> @(_isBulkDelete ? "All selected CVs and their" : "The CV and all") verification data will be permanently removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CancelDelete">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete" disabled="@_isDeleting">
|
||||||
|
@if (_isDeleting)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
<span>Deleting...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Delete @(_isBulkDelete ? $"{_checksToDelete.Count} Checks" : "")</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -373,6 +463,7 @@
|
|||||||
private List<CVCheckDto> _checks = [];
|
private List<CVCheckDto> _checks = [];
|
||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
private bool _isExporting;
|
private bool _isExporting;
|
||||||
|
private bool _isDeleting;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private Guid _userId;
|
private Guid _userId;
|
||||||
private System.Threading.Timer? _pollingTimer;
|
private System.Threading.Timer? _pollingTimer;
|
||||||
@@ -380,6 +471,15 @@
|
|||||||
private volatile bool _disposed;
|
private volatile bool _disposed;
|
||||||
private volatile bool _isOperationInProgress;
|
private volatile bool _isOperationInProgress;
|
||||||
|
|
||||||
|
// Delete confirmation modal state
|
||||||
|
private bool _showDeleteModal;
|
||||||
|
private CVCheckDto? _checkToDelete;
|
||||||
|
private List<CVCheckDto> _checksToDelete = [];
|
||||||
|
private bool _isBulkDelete;
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
private HashSet<Guid> _selectedIds = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadChecks();
|
await LoadChecks();
|
||||||
@@ -575,28 +675,106 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteCheck(Guid checkId)
|
// Selection methods
|
||||||
|
private void ToggleSelection(Guid id)
|
||||||
{
|
{
|
||||||
if (_isOperationInProgress) return;
|
if (_selectedIds.Contains(id))
|
||||||
|
_selectedIds.Remove(id);
|
||||||
|
else
|
||||||
|
_selectedIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
_isOperationInProgress = true;
|
private void ToggleSelectAll()
|
||||||
|
{
|
||||||
|
if (IsAllSelected())
|
||||||
|
_selectedIds.Clear();
|
||||||
|
else
|
||||||
|
_selectedIds = _checks.Select(c => c.Id).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAllSelected() => _checks.Count > 0 && _selectedIds.Count == _checks.Count;
|
||||||
|
|
||||||
|
// Single delete
|
||||||
|
private void ConfirmDelete(CVCheckDto check)
|
||||||
|
{
|
||||||
|
_checkToDelete = check;
|
||||||
|
_checksToDelete = [];
|
||||||
|
_isBulkDelete = false;
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete
|
||||||
|
private void ConfirmDeleteSelected()
|
||||||
|
{
|
||||||
|
_checksToDelete = _checks.Where(c => _selectedIds.Contains(c.Id)).ToList();
|
||||||
|
_checkToDelete = null;
|
||||||
|
_isBulkDelete = true;
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_checkToDelete = null;
|
||||||
|
_checksToDelete = [];
|
||||||
|
_isBulkDelete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteDelete()
|
||||||
|
{
|
||||||
|
if (_isDeleting) return;
|
||||||
|
|
||||||
|
_isDeleting = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await CVCheckService.DeleteCheckAsync(checkId, _userId);
|
if (_isBulkDelete)
|
||||||
|
{
|
||||||
|
var failedCount = 0;
|
||||||
|
foreach (var check in _checksToDelete)
|
||||||
|
{
|
||||||
|
var success = await CVCheckService.DeleteCheckAsync(check.Id, _userId);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
_checks.RemoveAll(c => c.Id == checkId);
|
_checks.RemoveAll(c => c.Id == check.Id);
|
||||||
StateHasChanged();
|
_selectedIds.Remove(check.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedCount > 0)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to delete {failedCount} CV check(s). Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_checkToDelete != null)
|
||||||
|
{
|
||||||
|
var success = await CVCheckService.DeleteCheckAsync(_checkToDelete.Id, _userId);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_checks.RemoveAll(c => c.Id == _checkToDelete.Id);
|
||||||
|
_selectedIds.Remove(_checkToDelete.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_errorMessage = "Failed to delete the CV check. Please try again.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error deleting CV check {CheckId}", checkId);
|
Logger.LogError(ex, "Error deleting CV check(s)");
|
||||||
_errorMessage = "Failed to delete the CV check. Please try again.";
|
_errorMessage = "Failed to delete CV check(s). Please try again.";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_isOperationInProgress = false;
|
_isDeleting = false;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_checkToDelete = null;
|
||||||
|
_checksToDelete = [];
|
||||||
|
_isBulkDelete = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user