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/