feat(adjustments): bulk-delete unpaid rows + floating action bar
New POST /payroll/adjustments/bulk-delete/ endpoint takes a list of
adjustment_ids and DELETEs the ones that are still unpaid
(payroll_record__isnull=True at the DB level) — paid rows are silently
skipped, defensive against stale-UI race conditions. Admin-only;
supervisors get 403. Returns JSON {deleted, requested}.
Floating bar slides up from the bottom of the viewport when >=1 row
selected: shows count + Delete + Clear. Confirm dialog guards the
POST. On success, page reloads to reflect the new state.
CSRF via X-CSRFToken header from the csrftoken cookie (Django
middleware sets this). Two new tests lock in the 'only unpaid' +
'admin-only' contracts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5d06f91e5
commit
03f177e7d0
@ -864,6 +864,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Floating bulk action bar: fixed at the bottom of the viewport, centred horizontally. #}
|
||||
{# Shown when >= 1 unpaid row is selected. CSS .adj-bulk-bar + animation shipped in Task 2. #}
|
||||
<div class="adj-bulk-bar" id="adjBulkBar" hidden>
|
||||
<span><strong id="adjBulkCount">0</strong> selected</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="adjBulkDeleteBtn">
|
||||
<i class="fas fa-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="adjBulkClearBtn">Clear</button>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@ -3582,6 +3592,96 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
).show();
|
||||
});
|
||||
});
|
||||
|
||||
// === ADJUSTMENTS TAB — bulk select + delete ===
|
||||
// The per-row checkboxes come from _adjustment_row.html (class
|
||||
// .adj-bulk-checkbox on unpaid rows only; disabled dummy checkbox on
|
||||
// paid rows for visual alignment). The header has an #adjSelectAll
|
||||
// that toggles all visible unpaid checkboxes. A floating action bar
|
||||
// (#adjBulkBar) appears when >=1 row is selected.
|
||||
var bulkBar = document.getElementById('adjBulkBar');
|
||||
var bulkCount = document.getElementById('adjBulkCount');
|
||||
var bulkDeleteBtn = document.getElementById('adjBulkDeleteBtn');
|
||||
var bulkClearBtn = document.getElementById('adjBulkClearBtn');
|
||||
var selectAll = document.getElementById('adjSelectAll');
|
||||
|
||||
// CSRF via cookie — Django middleware sets this cookie on GET requests.
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
return m ? decodeURIComponent(m[2]) : '';
|
||||
}
|
||||
|
||||
function getCheckedIds() {
|
||||
return Array.from(document.querySelectorAll('.adj-bulk-checkbox:checked'))
|
||||
.map(function(cb) { return cb.value; });
|
||||
}
|
||||
function refreshBulkBar() {
|
||||
if (!bulkBar || !bulkCount) return;
|
||||
var ids = getCheckedIds();
|
||||
bulkCount.textContent = ids.length;
|
||||
bulkBar.hidden = ids.length === 0;
|
||||
}
|
||||
|
||||
// Per-row checkbox change
|
||||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||||
cb.addEventListener('change', refreshBulkBar);
|
||||
});
|
||||
|
||||
// Select-all toggles all visible interactive checkboxes
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', function() {
|
||||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
refreshBulkBar();
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
if (bulkClearBtn) {
|
||||
bulkClearBtn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
if (selectAll) selectAll.checked = false;
|
||||
refreshBulkBar();
|
||||
});
|
||||
}
|
||||
|
||||
// Delete selected — confirm + POST + reload
|
||||
if (bulkDeleteBtn) {
|
||||
bulkDeleteBtn.addEventListener('click', function() {
|
||||
var ids = getCheckedIds();
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm('Delete ' + ids.length + ' adjustment' +
|
||||
(ids.length === 1 ? '' : 's') +
|
||||
'? This cannot be undone.')) return;
|
||||
|
||||
var form = new FormData();
|
||||
ids.forEach(function(id) { form.append('adjustment_ids', id); });
|
||||
|
||||
fetch('{% url "bulk_delete_adjustments" %}', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||
})
|
||||
.then(function(r) { return r.json().then(function(data) {
|
||||
return { ok: r.ok, data: data };
|
||||
}); })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
alert('Bulk delete failed: ' + (res.data && res.data.error || 'unknown error'));
|
||||
return;
|
||||
}
|
||||
// Simple reload — server-rendered view reflects the deletion
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Bulk delete failed: ' + err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}); // end DOMContentLoaded
|
||||
|
||||
@ -1097,3 +1097,39 @@ class AdjustmentsTabTests(TestCase):
|
||||
# Alice: +R 500 bonus + (-R 100) deduction = +R 400 net
|
||||
self.assertEqual(alice['count'], 2)
|
||||
self.assertEqual(alice['net_sum'], Decimal('400.00'))
|
||||
|
||||
def test_bulk_delete_only_affects_unpaid(self):
|
||||
"""POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
|
||||
deletes ONLY the unpaid rows. Paid rows are untouched (payroll
|
||||
history is immutable)."""
|
||||
self._login_admin()
|
||||
# Pay a1 (leave a2, a3 unpaid)
|
||||
pr = PayrollRecord.objects.create(
|
||||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||||
amount_paid=Decimal('4000'),
|
||||
)
|
||||
self.a1.payroll_record = pr
|
||||
self.a1.save()
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [self.a1.id, self.a2.id, self.a3.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body['deleted'], 2)
|
||||
self.assertEqual(body['requested'], 3)
|
||||
# a1 survives (paid), a2 + a3 gone
|
||||
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a3.id).exists())
|
||||
|
||||
def test_bulk_delete_requires_admin(self):
|
||||
"""Non-admin supervisors cannot bulk-delete adjustments."""
|
||||
self.client.login(username='adj-sup', password='pass')
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [self.a1.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
# a1 still present
|
||||
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
||||
|
||||
@ -54,6 +54,9 @@ urlpatterns = [
|
||||
# Delete an unpaid adjustment
|
||||
path('payroll/adjustment/<int:adj_id>/delete/', views.delete_adjustment, name='delete_adjustment'),
|
||||
|
||||
# Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
|
||||
path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),
|
||||
|
||||
# Preview a worker's payslip (AJAX — returns JSON)
|
||||
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.urls import reverse
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
@ -3943,6 +3944,46 @@ def delete_adjustment(request, adj_id):
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
|
||||
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
|
||||
# Only unpaid adjustments are deleted; paid rows survive because payroll
|
||||
# history is immutable (matches the existing edit_adjustment view, which
|
||||
# also refuses to edit paid rows).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def bulk_delete_adjustments(request):
|
||||
"""Delete multiple unpaid PayrollAdjustment rows in one DB call.
|
||||
|
||||
Body (form-encoded): `adjustment_ids` — repeated once per ID.
|
||||
Returns JSON: `{"deleted": N, "requested": M}`.
|
||||
Admin-only; supervisors get 403. POST-only; anything else gets 405
|
||||
from @require_POST.
|
||||
"""
|
||||
if not is_admin(request.user):
|
||||
return JsonResponse({'error': 'Admin access required'}, status=403)
|
||||
|
||||
ids = request.POST.getlist('adjustment_ids')
|
||||
# Defensive filter: only DELETE rows that are still unpaid.
|
||||
# If a user's browser had stale state showing a paid row as
|
||||
# unpaid, this quietly skips it instead of destroying payroll
|
||||
# history.
|
||||
to_delete = PayrollAdjustment.objects.filter(
|
||||
id__in=ids,
|
||||
payroll_record__isnull=True,
|
||||
)
|
||||
deleted_count = to_delete.count()
|
||||
to_delete.delete()
|
||||
|
||||
return JsonResponse({
|
||||
'deleted': deleted_count,
|
||||
'requested': len(ids),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PREVIEW PAYSLIP (AJAX) ===
|
||||
# Returns a JSON preview of what a worker's payslip would look like.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user