diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 0f93c5a..cdb8bd1 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -2124,157 +2124,232 @@ document.addEventListener('DOMContentLoaded', function() { return tr; } + // === BATCH PAY MODAL: Radio buttons control split mode === + // 'schedule' = split at last paydate (default), 'all' = pay everything unpaid + + // --- Helper: Load batch preview for the given mode --- + function loadBatchPreview(mode) { + var body = document.getElementById('batchPayModalBody'); + var footer = document.getElementById('batchPayModalFooter'); + footer.style.display = 'none'; + + // Keep radio buttons if they exist, clear everything else + var radioGroup = document.getElementById('batchModeRadioGroup'); + 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'); + loadDiv.className = 'text-center py-4'; + var spinner = document.createElement('div'); + spinner.className = 'spinner-border text-primary'; + spinner.setAttribute('role', 'status'); + loadDiv.appendChild(spinner); + var loadText = document.createElement('p'); + loadText.className = 'text-muted mt-2 small'; + loadText.textContent = 'Calculating pay periods...'; + loadDiv.appendChild(loadText); + body.appendChild(loadDiv); + + // Fetch batch pay preview with mode parameter + fetch('/payroll/batch-pay/preview/?mode=' + mode) + .then(function(resp) { return resp.json(); }) + .then(function(data) { + // Clear everything except the radio group + while (body.children.length > 1) body.removeChild(body.lastChild); + + // --- No eligible workers --- + if (data.eligible.length === 0) { + var noData = document.createElement('div'); + noData.className = 'text-center py-4'; + var icon = document.createElement('i'); + icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block'; + noData.appendChild(icon); + var msg = document.createElement('p'); + msg.className = 'text-muted'; + if (mode === 'schedule') { + 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); + body.appendChild(noData); + + if (data.skipped.length > 0) { + body.appendChild(buildSkippedSection(data.skipped)); + } + return; + } + + // --- Summary header --- + var summary = document.createElement('div'); + summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3'; + var leftSpan = document.createElement('span'); + var strong = document.createElement('strong'); + strong.textContent = data.worker_count + ' worker(s)'; + leftSpan.appendChild(strong); + leftSpan.appendChild(document.createTextNode(' eligible for payment')); + summary.appendChild(leftSpan); + var rightSpan = document.createElement('span'); + rightSpan.className = 'fw-bold'; + rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount); + summary.appendChild(rightSpan); + body.appendChild(summary); + + // --- Eligible workers table --- + var table = document.createElement('table'); + table.className = 'table table-sm table-hover mb-3'; + + // Table header + var thead = document.createElement('thead'); + var headerRow = document.createElement('tr'); + + var thCb = document.createElement('th'); + thCb.style.width = '30px'; + var selectAllCb = document.createElement('input'); + selectAllCb.type = 'checkbox'; + selectAllCb.id = 'batchSelectAll'; + selectAllCb.checked = true; + thCb.appendChild(selectAllCb); + headerRow.appendChild(thCb); + + ['Worker', 'Team', 'Period'].forEach(function(text) { + var th = document.createElement('th'); + th.textContent = text; + headerRow.appendChild(th); + }); + ['Days', 'Net Pay'].forEach(function(text) { + var th = document.createElement('th'); + th.className = 'text-end'; + th.textContent = text; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Table body + var tbody = document.createElement('tbody'); + data.eligible.forEach(function(w, idx) { + tbody.appendChild(buildWorkerRow(w, idx)); + }); + table.appendChild(tbody); + body.appendChild(table); + + // --- Select All checkbox behavior --- + selectAllCb.addEventListener('change', function() { + var cbs = body.querySelectorAll('.batch-worker-cb'); + for (var i = 0; i < cbs.length; i++) { + cbs[i].checked = selectAllCb.checked; + } + updateBatchSummary(data, summary); + }); + + // Update summary when individual checkboxes change + body.addEventListener('change', function(e) { + if (e.target.classList.contains('batch-worker-cb')) { + updateBatchSummary(data, summary); + var allCbs = body.querySelectorAll('.batch-worker-cb'); + var allChecked = true; + for (var i = 0; i < allCbs.length; i++) { + if (!allCbs[i].checked) { allChecked = false; break; } + } + selectAllCb.checked = allChecked; + } + }); + + // --- Skipped workers (collapsible) --- + if (data.skipped.length > 0) { + body.appendChild(buildSkippedSection(data.skipped)); + } + + // Store data for the confirm button + window._batchPayData = data.eligible; + + // Show footer with Confirm button + footer.style.display = ''; + + // Reset confirm button state + var cBtn = document.getElementById('confirmBatchPayBtn'); + cBtn.disabled = false; + while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild); + var btnIcon = document.createElement('i'); + btnIcon.className = 'fas fa-money-bill-wave me-1'; + cBtn.appendChild(btnIcon); + cBtn.appendChild(document.createTextNode('Confirm & Pay All')); + }) + .catch(function() { + while (body.children.length > 1) body.removeChild(body.lastChild); + var errDiv = document.createElement('div'); + errDiv.className = 'alert alert-danger'; + errDiv.textContent = 'Failed to load batch preview. Please try again.'; + body.appendChild(errDiv); + }); + } + var batchPayBtn = document.getElementById('batchPayBtn'); if (batchPayBtn) { batchPayBtn.addEventListener('click', function() { var modal = new bootstrap.Modal(document.getElementById('batchPayModal')); - var body = document.getElementById('batchPayModalBody'); - var footer = document.getElementById('batchPayModalFooter'); - footer.style.display = 'none'; - - // Show loading spinner - while (body.firstChild) body.removeChild(body.firstChild); - var loadDiv = document.createElement('div'); - loadDiv.className = 'text-center py-4'; - var spinner = document.createElement('div'); - spinner.className = 'spinner-border text-primary'; - spinner.setAttribute('role', 'status'); - loadDiv.appendChild(spinner); - var loadText = document.createElement('p'); - loadText.className = 'text-muted mt-2 small'; - loadText.textContent = 'Calculating pay periods...'; - loadDiv.appendChild(loadText); - body.appendChild(loadDiv); + // Reset radio group so it gets re-created fresh + var oldRadio = document.getElementById('batchModeRadioGroup'); + if (oldRadio) oldRadio.remove(); modal.show(); - - // Fetch batch pay preview (dry run — no payments made) - fetch('/payroll/batch-pay/preview/') - .then(function(resp) { return resp.json(); }) - .then(function(data) { - while (body.firstChild) body.removeChild(body.firstChild); - - // --- No eligible workers --- - if (data.eligible.length === 0) { - var noData = document.createElement('div'); - noData.className = 'text-center py-4'; - var icon = document.createElement('i'); - icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block'; - noData.appendChild(icon); - var msg = document.createElement('p'); - msg.className = 'text-muted'; - msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.'; - noData.appendChild(msg); - body.appendChild(noData); - - if (data.skipped.length > 0) { - body.appendChild(buildSkippedSection(data.skipped)); - } - return; - } - - // --- Summary header --- - var summary = document.createElement('div'); - summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3'; - var leftSpan = document.createElement('span'); - var strong = document.createElement('strong'); - strong.textContent = data.worker_count + ' worker(s)'; - leftSpan.appendChild(strong); - leftSpan.appendChild(document.createTextNode(' eligible for payment')); - summary.appendChild(leftSpan); - var rightSpan = document.createElement('span'); - rightSpan.className = 'fw-bold'; - rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount); - summary.appendChild(rightSpan); - body.appendChild(summary); - - // --- Eligible workers table --- - var table = document.createElement('table'); - table.className = 'table table-sm table-hover mb-3'; - - // Table header - var thead = document.createElement('thead'); - var headerRow = document.createElement('tr'); - - var thCb = document.createElement('th'); - thCb.style.width = '30px'; - var selectAllCb = document.createElement('input'); - selectAllCb.type = 'checkbox'; - selectAllCb.id = 'batchSelectAll'; - selectAllCb.checked = true; - thCb.appendChild(selectAllCb); - headerRow.appendChild(thCb); - - ['Worker', 'Team', 'Period'].forEach(function(text) { - var th = document.createElement('th'); - th.textContent = text; - headerRow.appendChild(th); - }); - ['Days', 'Net Pay'].forEach(function(text) { - var th = document.createElement('th'); - th.className = 'text-end'; - th.textContent = text; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - // Table body - var tbody = document.createElement('tbody'); - data.eligible.forEach(function(w, idx) { - tbody.appendChild(buildWorkerRow(w, idx)); - }); - table.appendChild(tbody); - body.appendChild(table); - - // --- Select All checkbox behavior --- - selectAllCb.addEventListener('change', function() { - var cbs = body.querySelectorAll('.batch-worker-cb'); - for (var i = 0; i < cbs.length; i++) { - cbs[i].checked = selectAllCb.checked; - } - updateBatchSummary(data, summary); - }); - - // Update summary when individual checkboxes change - body.addEventListener('change', function(e) { - if (e.target.classList.contains('batch-worker-cb')) { - updateBatchSummary(data, summary); - var allCbs = body.querySelectorAll('.batch-worker-cb'); - var allChecked = true; - for (var i = 0; i < allCbs.length; i++) { - if (!allCbs[i].checked) { allChecked = false; break; } - } - selectAllCb.checked = allChecked; - } - }); - - // --- Skipped workers (collapsible) --- - if (data.skipped.length > 0) { - body.appendChild(buildSkippedSection(data.skipped)); - } - - // Store data for the confirm button - window._batchPayData = data.eligible; - - // Show footer with Confirm button - footer.style.display = ''; - - // Reset confirm button state - var cBtn = document.getElementById('confirmBatchPayBtn'); - cBtn.disabled = false; - while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild); - var btnIcon = document.createElement('i'); - btnIcon.className = 'fas fa-money-bill-wave me-1'; - cBtn.appendChild(btnIcon); - cBtn.appendChild(document.createTextNode('Confirm & Pay All')); - }) - .catch(function() { - while (body.firstChild) body.removeChild(body.firstChild); - var errDiv = document.createElement('div'); - errDiv.className = 'alert alert-danger'; - errDiv.textContent = 'Failed to load batch preview. Please try again.'; - body.appendChild(errDiv); - }); + // Default mode: split at last paydate + loadBatchPreview('schedule'); }); } diff --git a/core/views.py b/core/views.py index b25ee0b..8d0090d 100644 --- a/core/views.py +++ b/core/views.py @@ -1404,10 +1404,15 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount, @login_required 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): return JsonResponse({'error': 'Not authorized'}, status=403) + # === MODE: 'schedule' (default) = split at last paydate, 'all' = pay everything === + mode = request.GET.get('mode', 'schedule') + eligible = [] skipped = [] total_amount = Decimal('0.00') @@ -1425,53 +1430,59 @@ def batch_pay_preview(request): ).order_by('name') for worker in active_workers: - # --- Check if worker has a team with a pay schedule --- team = get_worker_active_team(worker) - 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 - has_unpaid = False - for log in worker.work_logs.all(): - paid_ids = {pr.worker_id for pr in log.payroll_records.all()} - if worker.id not in paid_ids: - has_unpaid = True - break - if not has_unpaid: - has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists() - if has_unpaid: - skipped.append({ - 'worker_name': worker.name, - 'reason': 'No pay schedule configured', - }) - continue + # --- 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: + # Check if worker has ANY unpaid items before listing as skipped + has_unpaid = False + for log in worker.work_logs.all(): + paid_ids = {pr.worker_id for pr in log.payroll_records.all()} + if worker.id not in paid_ids: + has_unpaid = True + break + if not has_unpaid: + has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists() - # --- Get the current pay period and calculate the cutoff date --- - # cutoff_date = end of the last COMPLETED period. - # We pay ALL overdue work (across all past periods), not just one period. - period_start, period_end = get_pay_period(team) - if not period_start: - continue - cutoff_date = period_start - datetime.timedelta(days=1) + if has_unpaid: + skipped.append({ + 'worker_name': worker.name, + 'reason': 'No pay schedule configured', + }) + continue - # --- Find unpaid logs up to the cutoff date --- + # --- Determine cutoff date (if applicable) --- + cutoff_date = None + if mode == 'schedule': + # cutoff_date = end of the last COMPLETED period. + # We pay ALL overdue work (across all past periods), not just one period. + period_start, period_end = get_pay_period(team) + if not period_start: + continue + cutoff_date = period_start - datetime.timedelta(days=1) + + # --- Find unpaid logs (with or without cutoff) --- unpaid_log_ids = [] for log in worker.work_logs.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: - unpaid_log_ids.append(log.id) + 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) - # --- Find pending adjustments up to the cutoff date --- + # --- Find pending adjustments (with or without cutoff) --- unpaid_adj_ids = [] adj_amount = Decimal('0.00') 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) if adj.type in ADDITIVE_TYPES: adj_amount += adj.amount elif adj.type in DEDUCTIVE_TYPES: 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: continue @@ -1487,14 +1498,17 @@ def batch_pay_preview(request): }) continue - # Format: show "up to [cutoff date]" since we're paying all overdue work - # Use day integer to avoid platform-specific strftime issues (%-d on Linux, %#d on Windows) - period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}" + # --- Period display text --- + 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')}" + else: + period_display = "All unpaid" eligible.append({ 'worker_id': worker.id, 'worker_name': worker.name, - 'team_name': team.name, + 'team_name': team.name if team else '—', 'period': period_display, 'days': log_count, 'logs_amount': float(logs_amount), @@ -1510,6 +1524,7 @@ def batch_pay_preview(request): 'skipped': skipped, 'total_amount': float(total_amount), 'worker_count': len(eligible), + 'mode': mode, })