From 03f177e7d0d371fc24055c2e444ae44b942ac45c Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 23 Apr 2026 18:51:54 +0200 Subject: [PATCH] feat(adjustments): bulk-delete unpaid rows + floating action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/templates/core/payroll_dashboard.html | 100 +++++++++++++++++++++ core/tests.py | 36 ++++++++ core/urls.py | 3 + core/views.py | 41 +++++++++ 4 files changed, 180 insertions(+) diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index a253aa2..34ea07f 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -864,6 +864,16 @@ {% 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. #} + + {% endif %} @@ -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 diff --git a/core/tests.py b/core/tests.py index b3cdf27..6c021cd 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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()) diff --git a/core/urls.py b/core/urls.py index 29c860d..9e2122a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -54,6 +54,9 @@ urlpatterns = [ # Delete an unpaid adjustment path('payroll/adjustment//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//', views.preview_payslip, name='preview_payslip'), diff --git a/core/views.py b/core/views.py index 29a5777..e0871f8 100644 --- a/core/views.py +++ b/core/views.py @@ -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.