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:
Konrad du Plessis 2026-04-23 18:51:54 +02:00
parent e5d06f91e5
commit 03f177e7d0
4 changed files with 180 additions and 0 deletions

View File

@ -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

View File

@ -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())

View File

@ -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'),

View File

@ -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.