Add batch pay mode toggle: Until Last Paydate / Pay All

Radio buttons in the Batch Pay modal let admin choose between:
- "Until Last Paydate" (default): splits at last completed pay period
- "Pay All": includes all unpaid work regardless of pay schedule

Preview re-fetches when mode changes. Workers without teams are
included in Pay All mode (skipped in schedule mode as before).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-24 22:26:33 +02:00
parent 2e6881b7a4
commit 8d13c552aa
2 changed files with 271 additions and 181 deletions

View File

@ -2124,16 +2124,75 @@ document.addEventListener('DOMContentLoaded', function() {
return tr; return tr;
} }
var batchPayBtn = document.getElementById('batchPayBtn'); // === BATCH PAY MODAL: Radio buttons control split mode ===
if (batchPayBtn) { // 'schedule' = split at last paydate (default), 'all' = pay everything unpaid
batchPayBtn.addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById('batchPayModal')); // --- Helper: Load batch preview for the given mode ---
function loadBatchPreview(mode) {
var body = document.getElementById('batchPayModalBody'); var body = document.getElementById('batchPayModalBody');
var footer = document.getElementById('batchPayModalFooter'); var footer = document.getElementById('batchPayModalFooter');
footer.style.display = 'none'; footer.style.display = 'none';
// Show loading spinner // Keep radio buttons if they exist, clear everything else
var radioGroup = document.getElementById('batchModeRadioGroup');
while (body.firstChild) body.removeChild(body.firstChild); while (body.firstChild) body.removeChild(body.firstChild);
// === Radio button group (always at top) ===
if (!radioGroup) {
radioGroup = document.createElement('div');
radioGroup.id = 'batchModeRadioGroup';
radioGroup.className = 'btn-group w-100 mb-3';
radioGroup.setAttribute('role', 'group');
// "Until Last Paydate" radio
var radioSchedule = document.createElement('input');
radioSchedule.type = 'radio';
radioSchedule.className = 'btn-check';
radioSchedule.name = 'batchMode';
radioSchedule.id = 'batchModeSchedule';
radioSchedule.value = 'schedule';
radioSchedule.checked = (mode === 'schedule');
radioGroup.appendChild(radioSchedule);
var labelSchedule = document.createElement('label');
labelSchedule.className = 'btn btn-outline-primary';
labelSchedule.setAttribute('for', 'batchModeSchedule');
var iconSchedule = document.createElement('i');
iconSchedule.className = 'fas fa-calendar-check me-1';
labelSchedule.appendChild(iconSchedule);
labelSchedule.appendChild(document.createTextNode('Until Last Paydate'));
radioGroup.appendChild(labelSchedule);
// "Pay All" radio
var radioAll = document.createElement('input');
radioAll.type = 'radio';
radioAll.className = 'btn-check';
radioAll.name = 'batchMode';
radioAll.id = 'batchModeAll';
radioAll.value = 'all';
radioAll.checked = (mode === 'all');
radioGroup.appendChild(radioAll);
var labelAll = document.createElement('label');
labelAll.className = 'btn btn-outline-primary';
labelAll.setAttribute('for', 'batchModeAll');
var iconAll = document.createElement('i');
iconAll.className = 'fas fa-list me-1';
labelAll.appendChild(iconAll);
labelAll.appendChild(document.createTextNode('Pay All'));
radioGroup.appendChild(labelAll);
// Re-fetch preview when radio changes
radioSchedule.addEventListener('change', function() { loadBatchPreview('schedule'); });
radioAll.addEventListener('change', function() { loadBatchPreview('all'); });
} else {
// Update checked state on existing radios
document.getElementById('batchModeSchedule').checked = (mode === 'schedule');
document.getElementById('batchModeAll').checked = (mode === 'all');
}
body.appendChild(radioGroup);
// Show loading spinner below radio buttons
var loadDiv = document.createElement('div'); var loadDiv = document.createElement('div');
loadDiv.className = 'text-center py-4'; loadDiv.className = 'text-center py-4';
var spinner = document.createElement('div'); var spinner = document.createElement('div');
@ -2145,13 +2204,13 @@ document.addEventListener('DOMContentLoaded', function() {
loadText.textContent = 'Calculating pay periods...'; loadText.textContent = 'Calculating pay periods...';
loadDiv.appendChild(loadText); loadDiv.appendChild(loadText);
body.appendChild(loadDiv); body.appendChild(loadDiv);
modal.show();
// Fetch batch pay preview (dry run — no payments made) // Fetch batch pay preview with mode parameter
fetch('/payroll/batch-pay/preview/') fetch('/payroll/batch-pay/preview/?mode=' + mode)
.then(function(resp) { return resp.json(); }) .then(function(resp) { return resp.json(); })
.then(function(data) { .then(function(data) {
while (body.firstChild) body.removeChild(body.firstChild); // Clear everything except the radio group
while (body.children.length > 1) body.removeChild(body.lastChild);
// --- No eligible workers --- // --- No eligible workers ---
if (data.eligible.length === 0) { if (data.eligible.length === 0) {
@ -2162,7 +2221,11 @@ document.addEventListener('DOMContentLoaded', function() {
noData.appendChild(icon); noData.appendChild(icon);
var msg = document.createElement('p'); var msg = document.createElement('p');
msg.className = 'text-muted'; msg.className = 'text-muted';
if (mode === 'schedule') {
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.'; msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
} else {
msg.textContent = 'No workers with unpaid work found.';
}
noData.appendChild(msg); noData.appendChild(msg);
body.appendChild(noData); body.appendChild(noData);
@ -2269,12 +2332,24 @@ document.addEventListener('DOMContentLoaded', function() {
cBtn.appendChild(document.createTextNode('Confirm & Pay All')); cBtn.appendChild(document.createTextNode('Confirm & Pay All'));
}) })
.catch(function() { .catch(function() {
while (body.firstChild) body.removeChild(body.firstChild); while (body.children.length > 1) body.removeChild(body.lastChild);
var errDiv = document.createElement('div'); var errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger'; errDiv.className = 'alert alert-danger';
errDiv.textContent = 'Failed to load batch preview. Please try again.'; errDiv.textContent = 'Failed to load batch preview. Please try again.';
body.appendChild(errDiv); body.appendChild(errDiv);
}); });
}
var batchPayBtn = document.getElementById('batchPayBtn');
if (batchPayBtn) {
batchPayBtn.addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
// Reset radio group so it gets re-created fresh
var oldRadio = document.getElementById('batchModeRadioGroup');
if (oldRadio) oldRadio.remove();
modal.show();
// Default mode: split at last paydate
loadBatchPreview('schedule');
}); });
} }

View File

@ -1404,10 +1404,15 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
@login_required @login_required
def batch_pay_preview(request): def batch_pay_preview(request):
"""Return JSON preview of batch payment — who gets paid and how much.""" """Return JSON preview of batch payment — who gets paid and how much.
Accepts ?mode=all to skip pay-period cutoff and include ALL unpaid items.
Default mode is 'schedule' which splits at last completed pay period."""
if not is_admin(request.user): if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403) return JsonResponse({'error': 'Not authorized'}, status=403)
# === MODE: 'schedule' (default) = split at last paydate, 'all' = pay everything ===
mode = request.GET.get('mode', 'schedule')
eligible = [] eligible = []
skipped = [] skipped = []
total_amount = Decimal('0.00') total_amount = Decimal('0.00')
@ -1425,8 +1430,10 @@ def batch_pay_preview(request):
).order_by('name') ).order_by('name')
for worker in active_workers: for worker in active_workers:
# --- Check if worker has a team with a pay schedule ---
team = get_worker_active_team(worker) team = get_worker_active_team(worker)
# --- In 'schedule' mode, skip workers without a pay schedule ---
if mode == 'schedule':
if not team or not team.pay_frequency or not team.pay_start_date: if not team or not team.pay_frequency or not team.pay_start_date:
# Check if worker has ANY unpaid items before listing as skipped # Check if worker has ANY unpaid items before listing as skipped
has_unpaid = False has_unpaid = False
@ -1445,7 +1452,9 @@ def batch_pay_preview(request):
}) })
continue continue
# --- Get the current pay period and calculate the cutoff date --- # --- Determine cutoff date (if applicable) ---
cutoff_date = None
if mode == 'schedule':
# cutoff_date = end of the last COMPLETED period. # cutoff_date = end of the last COMPLETED period.
# We pay ALL overdue work (across all past periods), not just one period. # We pay ALL overdue work (across all past periods), not just one period.
period_start, period_end = get_pay_period(team) period_start, period_end = get_pay_period(team)
@ -1453,25 +1462,27 @@ def batch_pay_preview(request):
continue continue
cutoff_date = period_start - datetime.timedelta(days=1) cutoff_date = period_start - datetime.timedelta(days=1)
# --- Find unpaid logs up to the cutoff date --- # --- Find unpaid logs (with or without cutoff) ---
unpaid_log_ids = [] unpaid_log_ids = []
for log in worker.work_logs.all(): for log in worker.work_logs.all():
paid_ids = {pr.worker_id for pr in log.payroll_records.all()} paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
if worker.id not in paid_ids and log.date <= cutoff_date: if worker.id not in paid_ids:
# In 'all' mode: no date filter. In 'schedule' mode: only up to cutoff.
if cutoff_date is None or log.date <= cutoff_date:
unpaid_log_ids.append(log.id) unpaid_log_ids.append(log.id)
# --- Find pending adjustments up to the cutoff date --- # --- Find pending adjustments (with or without cutoff) ---
unpaid_adj_ids = [] unpaid_adj_ids = []
adj_amount = Decimal('0.00') adj_amount = Decimal('0.00')
for adj in worker.adjustments.all(): for adj in worker.adjustments.all():
if adj.date and adj.date <= cutoff_date: if cutoff_date is None or (adj.date and adj.date <= cutoff_date):
unpaid_adj_ids.append(adj.id) unpaid_adj_ids.append(adj.id)
if adj.type in ADDITIVE_TYPES: if adj.type in ADDITIVE_TYPES:
adj_amount += adj.amount adj_amount += adj.amount
elif adj.type in DEDUCTIVE_TYPES: elif adj.type in DEDUCTIVE_TYPES:
adj_amount -= adj.amount adj_amount -= adj.amount
# Nothing due for this worker (all work is in the current, incomplete period) # Nothing due for this worker
if not unpaid_log_ids and not unpaid_adj_ids: if not unpaid_log_ids and not unpaid_adj_ids:
continue continue
@ -1487,14 +1498,17 @@ def batch_pay_preview(request):
}) })
continue continue
# Format: show "up to [cutoff date]" since we're paying all overdue work # --- Period display text ---
# Use day integer to avoid platform-specific strftime issues (%-d on Linux, %#d on Windows) if cutoff_date:
# Use day integer to avoid platform-specific strftime issues
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}" period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
else:
period_display = "All unpaid"
eligible.append({ eligible.append({
'worker_id': worker.id, 'worker_id': worker.id,
'worker_name': worker.name, 'worker_name': worker.name,
'team_name': team.name, 'team_name': team.name if team else '',
'period': period_display, 'period': period_display,
'days': log_count, 'days': log_count,
'logs_amount': float(logs_amount), 'logs_amount': float(logs_amount),
@ -1510,6 +1524,7 @@ def batch_pay_preview(request):
'skipped': skipped, 'skipped': skipped,
'total_amount': float(total_amount), 'total_amount': float(total_amount),
'worker_count': len(eligible), 'worker_count': len(eligible),
'mode': mode,
}) })